@artefact-ci/sdd 0.0.1-stage → 0.2.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 +21 -0
- package/README.md +67 -0
- package/dist/agents.mjs +91 -0
- package/dist/artefact.mjs +41 -0
- package/dist/cli.mjs +70 -0
- package/dist/constitution.mjs +8 -0
- package/dist/errors.mjs +6 -0
- package/dist/examples/bun-hono.yml +17 -0
- package/dist/examples/nextjs.yml +25 -0
- package/dist/examples/nx.yml +35 -0
- package/dist/fs.mjs +10 -0
- package/dist/hooks.mjs +70 -0
- package/dist/install.mjs +261 -0
- package/dist/legacy.mjs +81 -0
- package/dist/report.mjs +91 -0
- package/dist/source.mjs +45 -0
- package/dist/specify.mjs +49 -0
- package/dist/stack.mjs +173 -0
- package/package.json +34 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artefact CI
|
|
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,67 @@
|
|
|
1
|
+
# @artefact-ci/sdd
|
|
2
|
+
|
|
3
|
+
L'installeur de la méthode Artefact CI. Il installe Spec Kit 1.3.2 (via `uvx`), l'extension git, l'extension `artefact-product` et le preset `artefact-sdd`, réapplique les hooks git, génère les fichiers d'agent de l'outil du projet, puis déduit `stack.yml` et écrit la constitution de base. Il ne pose aucune question : il fait, puis affiche un rapport.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @artefact-ci/sdd@X.Y.Z init [--integration <outil>]
|
|
9
|
+
npx @artefact-ci/sdd@X.Y.Z update
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Sans `--source`, l'installeur installe la release GitHub de sa propre version : le preset `artefact-sdd` et l'extension `artefact-product` du tag `vX.Y.Z`. Fixer la version dans la commande (`@X.Y.Z`) fixe donc celle de la méthode.
|
|
13
|
+
|
|
14
|
+
Pour développer la méthode, `--source` installe en `--dev` depuis un clone :
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
artefact-sdd init --source <clone de speckit-artefact> [--integration <outil>]
|
|
18
|
+
artefact-sdd update --source <clone de speckit-artefact>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Option | Rôle |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `--source <dir>` | clone de `speckit-artefact` d'où l'extension (`extension/`) et le preset (`preset/`) sont installés en `--dev`, pour développer la méthode. Sans cette option, la release GitHub de la version de l'installeur est installée. |
|
|
24
|
+
| `--integration <outil>` | outil du projet pour `specify init` (`claude` par défaut). Ignoré quand `.specify/` existe : l'intégration du projet est gardée. Ignoré par `update`. |
|
|
25
|
+
| `--help` | affiche l'usage |
|
|
26
|
+
|
|
27
|
+
Il faut [uv](https://docs.astral.sh/uv/) et Node 24.11 ou plus. Depuis ce dépôt : `pnpm --filter @artefact-ci/sdd build`, puis `node packages/sdd/dist/cli.mjs init --source .` dans le projet.
|
|
28
|
+
|
|
29
|
+
## Ce que `init` touche
|
|
30
|
+
|
|
31
|
+
| Étape | Effet |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| Spec Kit | `specify init --here` si `.specify/` est absent, avec `--force` dès que le dossier n'est pas vide (`.git` compris) |
|
|
34
|
+
| Migration | si le preset `boilerplate` est installé, il est retiré (`specify preset remove boilerplate`) |
|
|
35
|
+
| Composants | extension git, `artefact-product`, `artefact-sdd` ; déjà installés, ils ne sont pas réinstallés (c'est le rôle d'`update`) |
|
|
36
|
+
| Hooks | clé `hooks:` de `.specify/extensions.yml` : les 10 hooks git du boilerplate ; le reste du fichier est gardé |
|
|
37
|
+
| Agents | `.claude/agents/<nom>.md` (Claude Code) et `.agents/agents/<nom>/agent.md` (Antigravity, relecteurs seulement) ; ces fichiers lisent les consignes de `.specify/extensions/artefact-product/agents/` |
|
|
38
|
+
| `stack.yml` | écrit seulement s'il est absent ; déduit de `nx.json`, `next.config.*`, `bun.lock` et du gestionnaire de paquets ; une clé non déduite reste vide avec `# à compléter` |
|
|
39
|
+
| Constitution | `.specify/memory/constitution.md` écrite seulement si elle est absente ou encore le gabarit vierge |
|
|
40
|
+
| Version | `.specify/artefact.json` (voir plus bas) |
|
|
41
|
+
|
|
42
|
+
Un dossier `docs/agents/` hérité du boilerplate n'est jamais supprimé : le rapport le liste comme obsolète et dit si chaque consigne est identique à celle du boilerplate ou modifiée sur place (à reporter alors dans les docs du projet).
|
|
43
|
+
|
|
44
|
+
`init` lancé deux fois de suite laisse `git status` vide la deuxième fois.
|
|
45
|
+
|
|
46
|
+
## La version installée : `.specify/artefact.json`
|
|
47
|
+
|
|
48
|
+
L'installeur enregistre la version de la méthode installée et sa source dans `.specify/artefact.json`, en JSON indenté de 2 espaces :
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"version": "0.2.1",
|
|
53
|
+
"source": "release"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`source` vaut `release` (zips de la release GitHub) ou `dev` (`--source`, la version est alors celle de `preset/preset.yml` du clone). Le fichier ne contient ni date ni chemin. Un fichier illisible ou incomplet arrête `init` et `update` avant toute commande Spec Kit, sans être réécrit.
|
|
58
|
+
|
|
59
|
+
`init` écrit ce fichier sur un projet neuf (aucun des deux composants installé) et le laisse tel quel quand il donne déjà la même version. Si `.specify/artefact.json` donne une autre version que celle de l'installeur, `init` refuse sans rien installer et renvoie vers `update`, seul à changer de version.
|
|
60
|
+
|
|
61
|
+
Un projet installé avant ce fichier (phase 4, avec `--source`) a déjà les composants mais pas `.specify/artefact.json` : `init` n'en écrit pas, et son rapport affiche « Version installée inconnue : lance update pour aligner tous les composants ». Lancer `update`, qui réinstalle tout et écrit le fichier.
|
|
62
|
+
|
|
63
|
+
`update` écrit toujours le fichier, et son rapport donne la version précédente. S'il installe une version plus ancienne que celle enregistrée, il prévient et continue.
|
|
64
|
+
|
|
65
|
+
## Ce que `update` touche
|
|
66
|
+
|
|
67
|
+
Il réinstalle l'extension et le preset, puis régénère les fichiers d'agent et réapplique les hooks. Quand `.specify/integration.json` liste plusieurs outils, il lance `specify integration use` pour chaque outil autre que celui par défaut, puis pour l'outil par défaut : Spec Kit n'enregistre les commandes de l'extension et du preset que pour l'outil sélectionné. L'outil par défaut ne change pas, même si un `use` échoue, et les scripts de `.specify/scripts/` gardent leurs droits. Sans `--source`, il les prend dans les zips de la release de sa version : `extension add --from <url du zip> --force` et `preset update artefact-sdd --from <url du zip>`. Avec `--source`, il les prend dans le clone en `--dev` : `extension add --dev … --force` et `preset update artefact-sdd --dev …` (soit un retrait puis un ajout). Si un téléchargement échoue (réseau coupé, version sans release), l'installeur s'arrête en citant l'URL du zip : relancer la même commande une fois le réseau rétabli. `installed_at` change dans les registres de `.specify/` : c'est attendu. Il ne touche jamais `stack.yml` ni la constitution, et s'arrête si `.specify/` est absent.
|
package/dist/agents.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
/** Dossier où Spec Kit installe les consignes des sous-agents (relatif à la racine du projet). */
|
|
5
|
+
export const INSTRUCTIONS_DIR = '.specify/extensions/artefact-product/agents';
|
|
6
|
+
export function readAgentSpecs(root) {
|
|
7
|
+
const file = join(root, INSTRUCTIONS_DIR, 'agents.yml');
|
|
8
|
+
if (!existsSync(file)) {
|
|
9
|
+
throw new Error(`${INSTRUCTIONS_DIR}/agents.yml introuvable dans ${root} : l'extension artefact-product est-elle installée ?`);
|
|
10
|
+
}
|
|
11
|
+
const doc = parse(readFileSync(file, 'utf8'));
|
|
12
|
+
if (!doc || !Array.isArray(doc.agents)) {
|
|
13
|
+
throw new Error(`${INSTRUCTIONS_DIR}/agents.yml invalide : clé « agents » absente`);
|
|
14
|
+
}
|
|
15
|
+
return doc.agents.map((a) => ({ name: a.name, access: a.access, description: a.description }));
|
|
16
|
+
}
|
|
17
|
+
/** Intégrations Spec Kit installées (`.specify/integration.json`) ; [] si le fichier manque. */
|
|
18
|
+
export function readIntegrations(root) {
|
|
19
|
+
const file = join(root, '.specify', 'integration.json');
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return [];
|
|
22
|
+
const json = JSON.parse(readFileSync(file, 'utf8'));
|
|
23
|
+
if (Array.isArray(json.installed_integrations))
|
|
24
|
+
return json.installed_integrations;
|
|
25
|
+
return json.integration ? [json.integration] : [];
|
|
26
|
+
}
|
|
27
|
+
/** Intégration par défaut de `.specify/integration.json` (`default_integration`, sinon `integration`) ; null si aucune. */
|
|
28
|
+
export function readDefaultIntegration(root) {
|
|
29
|
+
const file = join(root, '.specify', 'integration.json');
|
|
30
|
+
if (!existsSync(file))
|
|
31
|
+
return null;
|
|
32
|
+
const json = JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
return json.default_integration ?? json.integration ?? null;
|
|
34
|
+
}
|
|
35
|
+
function body(spec) {
|
|
36
|
+
const lines = [
|
|
37
|
+
`Lis \`${INSTRUCTIONS_DIR}/${spec.name}.md\` et applique-le à la lettre : méthode, grille et format du rapport.`,
|
|
38
|
+
];
|
|
39
|
+
lines.push(spec.access === 'read-only'
|
|
40
|
+
? "Tu n'as pas écrit ce travail. Ne modifie aucun fichier, ne commite rien. Rends uniquement le rapport."
|
|
41
|
+
: "Ne commite rien : la commande qui t'a lancé s'en charge après l'accord de l'utilisateur.");
|
|
42
|
+
return lines.join('\n\n');
|
|
43
|
+
}
|
|
44
|
+
function document(front, spec) {
|
|
45
|
+
return `---\n${stringify(front, { lineWidth: 0 })}---\n\n${body(spec)}\n`;
|
|
46
|
+
}
|
|
47
|
+
const CLAUDE_TOOLS = {
|
|
48
|
+
'read-only': 'Read, Grep, Glob, Bash',
|
|
49
|
+
writes: 'Read, Grep, Glob, Bash, Write, Edit',
|
|
50
|
+
};
|
|
51
|
+
// Antigravity : noms d'outils pris verbatim sur https://antigravity.google/docs/subagents
|
|
52
|
+
// (consulté le 2026-11-10) : view_file, grep_search, run_command (exemple de frontmatter).
|
|
53
|
+
// La doc ne nomme aucun outil pour créer un fichier ni pour lister un dossier (le listing
|
|
54
|
+
// passe par run_command) : les agents `writes` sont donc ignorés sur agy jusqu'à une
|
|
55
|
+
// vérification manuelle. Un nom faux ferait se bloquer le sous-agent.
|
|
56
|
+
const AGY_TOOLS = ['view_file', 'grep_search', 'run_command'];
|
|
57
|
+
const GENERATORS = {
|
|
58
|
+
claude: {
|
|
59
|
+
supports: () => true,
|
|
60
|
+
file: (spec) => ({
|
|
61
|
+
path: `.claude/agents/${spec.name}.md`,
|
|
62
|
+
content: document({ name: spec.name, description: spec.description, tools: CLAUDE_TOOLS[spec.access] }, spec),
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
agy: {
|
|
66
|
+
supports: (spec) => spec.access === 'read-only',
|
|
67
|
+
file: (spec) => ({
|
|
68
|
+
path: `.agents/agents/${spec.name}/agent.md`,
|
|
69
|
+
content: document({
|
|
70
|
+
name: spec.name,
|
|
71
|
+
description: spec.description,
|
|
72
|
+
tools: AGY_TOOLS,
|
|
73
|
+
commandExecutionPolicy: 'sandbox',
|
|
74
|
+
}, spec),
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
/** Fichiers d'agent d'une intégration ; null si l'outil n'a pas de générateur. Pure. */
|
|
79
|
+
export function agentFiles(integration, specs) {
|
|
80
|
+
const gen = GENERATORS[integration];
|
|
81
|
+
if (!gen)
|
|
82
|
+
return null;
|
|
83
|
+
return specs.filter(gen.supports).map(gen.file);
|
|
84
|
+
}
|
|
85
|
+
/** Noms des agents sans fichier généré pour cette intégration ([] si inconnue ou complète). */
|
|
86
|
+
export function skippedAgents(integration, specs) {
|
|
87
|
+
const gen = GENERATORS[integration];
|
|
88
|
+
if (!gen)
|
|
89
|
+
return [];
|
|
90
|
+
return specs.filter((s) => !gen.supports(s)).map((s) => s.name);
|
|
91
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { SetupError } from "./errors.mjs";
|
|
4
|
+
import { writeIfChanged } from "./fs.mjs";
|
|
5
|
+
export const ARTEFACT_JSON = '.specify/artefact.json';
|
|
6
|
+
/** Lit `.specify/artefact.json` ; null si absent, SetupError s'il est illisible ou incomplet. */
|
|
7
|
+
export function readInstalled(root) {
|
|
8
|
+
const file = join(root, ARTEFACT_JSON);
|
|
9
|
+
if (!existsSync(file))
|
|
10
|
+
return null;
|
|
11
|
+
let json;
|
|
12
|
+
try {
|
|
13
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
throw new SetupError(`${ARTEFACT_JSON} illisible : ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
const o = (typeof json === 'object' && json !== null ? json : {});
|
|
19
|
+
if (typeof o['version'] !== 'string' || o['version'] === '') {
|
|
20
|
+
throw new SetupError(`${ARTEFACT_JSON} : version manque ou n'est pas une chaîne`);
|
|
21
|
+
}
|
|
22
|
+
if (o['source'] !== 'release' && o['source'] !== 'dev') {
|
|
23
|
+
throw new SetupError(`${ARTEFACT_JSON} : source doit valoir release ou dev`);
|
|
24
|
+
}
|
|
25
|
+
return { version: o['version'], source: o['source'] };
|
|
26
|
+
}
|
|
27
|
+
/** Écrit `.specify/artefact.json` ; vrai si le contenu a changé. */
|
|
28
|
+
export function writeInstalled(root, i) {
|
|
29
|
+
return writeIfChanged(join(root, ARTEFACT_JSON), `${JSON.stringify(i, null, 2)}\n`);
|
|
30
|
+
}
|
|
31
|
+
/** Compare deux versions `X.Y.Z` numériquement : négatif, nul ou positif. */
|
|
32
|
+
export function compareVersions(a, b) {
|
|
33
|
+
const pa = a.split('.').map(Number);
|
|
34
|
+
const pb = b.split('.').map(Number);
|
|
35
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
36
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
37
|
+
if (d !== 0)
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Installe la méthode Artefact CI dans le dépôt courant (Spec Kit, preset artefact-sdd,
|
|
4
|
+
* extension artefact-product, agents de relecture, stack.yml, constitution).
|
|
5
|
+
* artefact-sdd init [--integration <outil>]
|
|
6
|
+
* artefact-sdd update
|
|
7
|
+
* Sans --source, l'installeur installe la release GitHub de sa propre version.
|
|
8
|
+
* artefact-sdd init|update --source <clone de speckit-artefact>
|
|
9
|
+
* Avec --source, il installe en --dev depuis ce clone (développement de la méthode).
|
|
10
|
+
* init est idempotent ; update réinstalle les composants sans toucher stack.yml ni la constitution.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
15
|
+
import { init, SetupError, update } from "./install.mjs";
|
|
16
|
+
import { formatReport } from "./report.mjs";
|
|
17
|
+
import { devSource, releaseSource } from "./source.mjs";
|
|
18
|
+
import { run, SpecifyError } from "./specify.mjs";
|
|
19
|
+
const USAGE = 'Usage : artefact-sdd init|update [--source <dir>] [--integration <outil>] [--help]';
|
|
20
|
+
function fail(message) {
|
|
21
|
+
console.error(message);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = parseArgs({
|
|
27
|
+
args: process.argv.slice(2),
|
|
28
|
+
allowPositionals: true,
|
|
29
|
+
options: {
|
|
30
|
+
source: { type: 'string' },
|
|
31
|
+
integration: { type: 'string' },
|
|
32
|
+
help: { type: 'boolean', short: 'h' },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
fail(`${error.message}\n${USAGE}`);
|
|
38
|
+
}
|
|
39
|
+
const { values, positionals } = parsed;
|
|
40
|
+
if (values.help) {
|
|
41
|
+
console.log(USAGE);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
const [command, ...extra] = positionals;
|
|
45
|
+
if (command === undefined)
|
|
46
|
+
fail(USAGE);
|
|
47
|
+
if (command !== 'init' && command !== 'update')
|
|
48
|
+
fail(`Commande inconnue : ${command}\n${USAGE}`);
|
|
49
|
+
if (extra.length > 0)
|
|
50
|
+
fail(`Argument en trop : ${extra.join(' ')}\n${USAGE}`);
|
|
51
|
+
if (command === 'update' && values.integration !== undefined) {
|
|
52
|
+
console.error("Avertissement : --integration est ignoré par update, l'intégration du projet est gardée.");
|
|
53
|
+
}
|
|
54
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
55
|
+
try {
|
|
56
|
+
const options = {
|
|
57
|
+
root: process.cwd(),
|
|
58
|
+
source: values.source ? devSource(resolve(values.source)) : releaseSource(pkg.version),
|
|
59
|
+
toolsVersion: pkg.version,
|
|
60
|
+
runner: run,
|
|
61
|
+
...(command === 'init' && values.integration !== undefined ? { integration: values.integration } : {}),
|
|
62
|
+
};
|
|
63
|
+
const report = command === 'init' ? await init(options) : await update(options);
|
|
64
|
+
process.stdout.write(formatReport(report));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof SetupError || error instanceof SpecifyError)
|
|
68
|
+
fail(error.message);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Vrai si le texte est encore la constitution vierge du cœur Spec Kit (aucun installeur passé). */
|
|
2
|
+
export function isBlankConstitution(text) {
|
|
3
|
+
return text.includes('[PROJECT_NAME]') && text.includes('[PRINCIPLE_1_NAME]');
|
|
4
|
+
}
|
|
5
|
+
/** Renseigne le nom du projet ; les autres marqueurs restent à remplir. */
|
|
6
|
+
export function baseConstitution(template, projectName) {
|
|
7
|
+
return template.replaceAll('[PROJECT_NAME]', () => projectName);
|
|
8
|
+
}
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# .specify/stack.yml : API Bun + Hono sans front (exemple : avia-agent, à valider en phase 6b)
|
|
2
|
+
version: 1
|
|
3
|
+
branches:
|
|
4
|
+
base: dev
|
|
5
|
+
production: main
|
|
6
|
+
docs:
|
|
7
|
+
- AGENTS.md
|
|
8
|
+
commands:
|
|
9
|
+
test: bun test {file}
|
|
10
|
+
affected: bun run lint && bun run typecheck && bun test
|
|
11
|
+
check: bun run check
|
|
12
|
+
trace: bunx @artefact-ci/spec-trace
|
|
13
|
+
tests:
|
|
14
|
+
globs: ['src/**/*.test.ts']
|
|
15
|
+
flags: false
|
|
16
|
+
ui: null # pas de front : /speckit-design n'écrit que la partie non visuelle, /speckit-prototype s'arrête
|
|
17
|
+
e2e: null
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# .specify/stack.yml : application Next.js (exemple : avia-client, à valider en phase 6b)
|
|
2
|
+
version: 1
|
|
3
|
+
branches:
|
|
4
|
+
base: dev
|
|
5
|
+
production: main
|
|
6
|
+
docs:
|
|
7
|
+
- AGENTS.md
|
|
8
|
+
commands:
|
|
9
|
+
test: pnpm vitest run {file}
|
|
10
|
+
affected: pnpm lint && pnpm typecheck && pnpm vitest run
|
|
11
|
+
check: pnpm check
|
|
12
|
+
trace: pnpm dlx @artefact-ci/spec-trace
|
|
13
|
+
tests:
|
|
14
|
+
globs: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'e2e/**/*.spec.ts']
|
|
15
|
+
needs: []
|
|
16
|
+
flags: false
|
|
17
|
+
ui:
|
|
18
|
+
components: src/components/ui
|
|
19
|
+
storybook: false
|
|
20
|
+
theme: src/app/globals.css
|
|
21
|
+
prototype: pnpm dlx @artefact-ci/prototype # paquet de la phase 3
|
|
22
|
+
e2e:
|
|
23
|
+
dir: e2e
|
|
24
|
+
command: pnpm exec playwright test
|
|
25
|
+
setup: pnpm exec playwright install chromium
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# .specify/stack.yml : nx-react-nestjs (Nx, NestJS, React)
|
|
2
|
+
version: 1
|
|
3
|
+
branches:
|
|
4
|
+
base: dev
|
|
5
|
+
production: main
|
|
6
|
+
docs:
|
|
7
|
+
- AGENTS.md
|
|
8
|
+
- apps/web/AGENTS.md
|
|
9
|
+
- libs/web/AGENTS.md
|
|
10
|
+
commands:
|
|
11
|
+
test: pnpm nx test {project}
|
|
12
|
+
affected: pnpm nx affected -t lint typecheck test
|
|
13
|
+
fresh: pnpm nx affected -t lint typecheck test --skip-nx-cache
|
|
14
|
+
check: pnpm check
|
|
15
|
+
trace: pnpm -s spec:trace
|
|
16
|
+
tests:
|
|
17
|
+
globs: ['**/*.spec.ts', '**/*.spec.tsx']
|
|
18
|
+
needs: [docker] # Testcontainers
|
|
19
|
+
shared:
|
|
20
|
+
- libs/api/core
|
|
21
|
+
- libs/shared/contracts
|
|
22
|
+
- libs/web/data-access
|
|
23
|
+
- apps/auth
|
|
24
|
+
flags: true # feature flags : catalogue FEATURE_FLAGS, @RequireFeature, requireFeature (AGENTS.md)
|
|
25
|
+
ui:
|
|
26
|
+
components: libs/web/ui
|
|
27
|
+
storybook: true
|
|
28
|
+
theme: libs/web/ui/src/lib/styles/theme.css
|
|
29
|
+
preview: pnpm nx run web-ui:storybook # http://localhost:6006
|
|
30
|
+
prototype: pnpm prototype
|
|
31
|
+
e2e:
|
|
32
|
+
dir: apps/web-e2e
|
|
33
|
+
command: pnpm nx affected -t e2e
|
|
34
|
+
fresh: pnpm nx affected -t e2e --skip-nx-cache
|
|
35
|
+
setup: pnpm exec playwright install chromium
|
package/dist/fs.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/** Écrit `content` seulement s'il diffère du fichier (crée les dossiers) ; vrai si écrit. */
|
|
4
|
+
export function writeIfChanged(path, content) {
|
|
5
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content)
|
|
6
|
+
return false;
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
writeFileSync(path, content);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
package/dist/hooks.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
2
|
+
const hook = (command, prompt, description) => ({
|
|
3
|
+
extension: 'git',
|
|
4
|
+
command: `speckit.git.${command}`,
|
|
5
|
+
enabled: true,
|
|
6
|
+
optional: false,
|
|
7
|
+
priority: 10,
|
|
8
|
+
prompt,
|
|
9
|
+
description,
|
|
10
|
+
condition: null,
|
|
11
|
+
});
|
|
12
|
+
/** Les 10 hooks git du boilerplate, dans l'ordre de ses événements. */
|
|
13
|
+
const rows = [
|
|
14
|
+
['before_specify', 'feature', 'Execute speckit.git.feature?', 'Create feature branch before specification'],
|
|
15
|
+
['after_specify', 'commit', 'Commit specify changes?', 'Auto-commit after specification'],
|
|
16
|
+
['after_clarify', 'commit', 'Commit clarify changes?', 'Auto-commit after spec clarification'],
|
|
17
|
+
['after_plan', 'commit', 'Commit plan changes?', 'Auto-commit after implementation planning'],
|
|
18
|
+
['after_tasks', 'commit', 'Commit tasks changes?', 'Auto-commit after task generation'],
|
|
19
|
+
['after_checklist', 'commit', 'Commit checklist changes?', 'Auto-commit after checklist generation'],
|
|
20
|
+
['after_analyze', 'commit', 'Commit analysis corrections?', 'Auto-commit the corrections made after the analysis'],
|
|
21
|
+
['after_implement', 'commit', 'Commit implement changes?', 'Auto-commit after implementation'],
|
|
22
|
+
['after_converge', 'commit', 'Commit converge changes?', 'Auto-commit after convergence'],
|
|
23
|
+
['after_constitution', 'commit', 'Commit constitution changes?', 'Auto-commit after constitution update'],
|
|
24
|
+
];
|
|
25
|
+
export const GIT_HOOKS = rows.map(([event, command, prompt, description]) => [event, hook(command, prompt, description)]);
|
|
26
|
+
const HEADER = ` Wired on purpose: branch creation before specify, and a commit after each command that writes files
|
|
27
|
+
(after analyze too: it reports only, but the corrections applied right after it get committed; with
|
|
28
|
+
nothing changed the hook does nothing). The extension's other hooks (repository initialization,
|
|
29
|
+
commits before each command, taskstoissues) are left out. \`specify extension update git\` re-adds them:
|
|
30
|
+
\`npx @artefact-ci/sdd update\` reapplies them.`;
|
|
31
|
+
const isGit = (item) => isMap(item) && item.get('extension') === 'git';
|
|
32
|
+
/**
|
|
33
|
+
* Ramène les hooks git d'un `.specify/extensions.yml` aux 10 du boilerplate : retire toutes les entrées
|
|
34
|
+
* `extension: git`, supprime les événements devenus vides, ajoute les 10 hooks (après les entrées des
|
|
35
|
+
* autres extensions d'un même événement). Conserve `installed`, les autres extensions et les commentaires.
|
|
36
|
+
*/
|
|
37
|
+
export function applyGitHooks(extensionsYml) {
|
|
38
|
+
const doc = parseDocument(extensionsYml);
|
|
39
|
+
if (doc.errors.length > 0)
|
|
40
|
+
throw new Error(`extensions.yml invalide : ${doc.errors[0]?.message}`);
|
|
41
|
+
if (doc.contents === null)
|
|
42
|
+
doc.contents = doc.createNode({});
|
|
43
|
+
if (!isMap(doc.contents))
|
|
44
|
+
throw new Error('extensions.yml invalide : un mapping est attendu à la racine');
|
|
45
|
+
doc.setIn(['settings', 'auto_execute_hooks'], true);
|
|
46
|
+
if (!isMap(doc.get('hooks')))
|
|
47
|
+
doc.set(doc.createNode('hooks'), doc.createNode({}));
|
|
48
|
+
const hooks = doc.get('hooks');
|
|
49
|
+
for (const pair of [...hooks.items]) {
|
|
50
|
+
const seq = pair.value;
|
|
51
|
+
if (!isSeq(seq))
|
|
52
|
+
continue;
|
|
53
|
+
seq.items = seq.items.filter((item) => !isGit(item));
|
|
54
|
+
if (seq.items.length === 0)
|
|
55
|
+
hooks.items = hooks.items.filter((p) => p !== pair);
|
|
56
|
+
}
|
|
57
|
+
for (const [event, entry] of GIT_HOOKS) {
|
|
58
|
+
let seq = hooks.get(event);
|
|
59
|
+
if (!isSeq(seq)) {
|
|
60
|
+
seq = doc.createNode([]);
|
|
61
|
+
hooks.set(event, seq);
|
|
62
|
+
}
|
|
63
|
+
seq.add(doc.createNode(entry));
|
|
64
|
+
}
|
|
65
|
+
const key = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === 'hooks')?.key;
|
|
66
|
+
if (isScalar(key) && !(key.commentBefore ?? '').includes('Wired on purpose')) {
|
|
67
|
+
key.commentBefore = key.commentBefore ? `${key.commentBefore}\n${HEADER}` : HEADER;
|
|
68
|
+
}
|
|
69
|
+
return doc.toString({ indentSeq: false });
|
|
70
|
+
}
|
package/dist/install.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { agentFiles, readAgentSpecs, readDefaultIntegration, readIntegrations, skippedAgents } from "./agents.mjs";
|
|
4
|
+
import { compareVersions, readInstalled, writeInstalled } from "./artefact.mjs";
|
|
5
|
+
import { baseConstitution, isBlankConstitution } from "./constitution.mjs";
|
|
6
|
+
import { applyGitHooks } from "./hooks.mjs";
|
|
7
|
+
import { legacyAgents } from "./legacy.mjs";
|
|
8
|
+
import { SetupError } from "./errors.mjs";
|
|
9
|
+
import { writeIfChanged } from "./fs.mjs";
|
|
10
|
+
import { componentArgs, zipUrl } from "./source.mjs";
|
|
11
|
+
import { hasUv, specify, SpecifyError } from "./specify.mjs";
|
|
12
|
+
import { deduceStack, defaultExamplesDir } from "./stack.mjs";
|
|
13
|
+
export { SetupError, writeIfChanged };
|
|
14
|
+
const UV_MISSING = 'uv manque : https://docs.astral.sh/uv/getting-started/installation/';
|
|
15
|
+
const CONSTITUTION = '.specify/memory/constitution.md';
|
|
16
|
+
const STACK = '.specify/stack.yml';
|
|
17
|
+
const EXTENSIONS_YML = '.specify/extensions.yml';
|
|
18
|
+
const PRESET_TEMPLATE = '.specify/presets/artefact-sdd/templates/constitution-template.md';
|
|
19
|
+
/** Vrai si `id` est une clé du `.registry` de `kind` ; registre absent → faux. */
|
|
20
|
+
function registered(root, kind, id) {
|
|
21
|
+
const rel = `.specify/${kind}/.registry`;
|
|
22
|
+
const file = join(root, rel);
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
return false;
|
|
25
|
+
let json;
|
|
26
|
+
try {
|
|
27
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new SetupError(`${rel} illisible : ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
const map = json?.[kind];
|
|
33
|
+
return typeof map === 'object' && map !== null && id in map;
|
|
34
|
+
}
|
|
35
|
+
const COMPONENTS = [
|
|
36
|
+
{
|
|
37
|
+
id: 'git',
|
|
38
|
+
kind: 'extensions',
|
|
39
|
+
install: (_s, present) => ({ args: ['extension', 'add', 'git', ...(present ? ['--force'] : [])] }),
|
|
40
|
+
},
|
|
41
|
+
{ id: 'artefact-product', kind: 'extensions', install: (s, present) => componentArgs(s, 'artefact-product', present) },
|
|
42
|
+
{ id: 'artefact-sdd', kind: 'presets', install: (s, present) => componentArgs(s, 'artefact-sdd', present) },
|
|
43
|
+
];
|
|
44
|
+
/** stdout d'une commande git, ou null si elle échoue (pas un dépôt, pas de commit…). */
|
|
45
|
+
async function git(o, args) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await o.runner('git', args, { cwd: o.root });
|
|
48
|
+
return r.code === 0 ? r.stdout : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Branches locales et de `origin` (préfixe retiré, dédupliquées) et cible de `origin/HEAD` ; vide hors dépôt. */
|
|
55
|
+
async function branches(o) {
|
|
56
|
+
const refs = ((await git(o, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin'])) ?? '')
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((r) => r.trim())
|
|
59
|
+
.filter((r) => r !== '' && r !== 'refs/remotes/origin/HEAD')
|
|
60
|
+
.map((r) => r.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, ''));
|
|
61
|
+
const head = (await git(o, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']))?.trim();
|
|
62
|
+
const defaultBranch = head?.startsWith('refs/remotes/origin/') ? head.slice('refs/remotes/origin/'.length) : undefined;
|
|
63
|
+
return { names: [...new Set(refs)], ...(defaultBranch ? { defaultBranch } : {}) };
|
|
64
|
+
}
|
|
65
|
+
const SCRIPTS = '.specify/scripts';
|
|
66
|
+
/** Droits de chaque fichier sous `.specify/scripts/` (chemin absolu → mode). */
|
|
67
|
+
function scriptModes(root) {
|
|
68
|
+
const modes = new Map();
|
|
69
|
+
const walk = (dir) => {
|
|
70
|
+
if (!existsSync(dir))
|
|
71
|
+
return;
|
|
72
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
73
|
+
const p = join(dir, e.name);
|
|
74
|
+
if (e.isDirectory())
|
|
75
|
+
walk(p);
|
|
76
|
+
else if (e.isFile())
|
|
77
|
+
modes.set(p, statSync(p).mode & 0o777);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(join(root, SCRIPTS));
|
|
81
|
+
return modes;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Spec Kit 1.3.2 n'enregistre les commandes d'une extension ou d'un preset que pour l'intégration
|
|
85
|
+
* par défaut ; une autre ne les reçoit qu'à `specify integration use`. On sélectionne donc chaque
|
|
86
|
+
* intégration secondaire, puis on revient toujours à celle par défaut, même après un échec.
|
|
87
|
+
* `use` réécrit les scripts partagés sans leur droit d'exécution : on remet les droits d'avant.
|
|
88
|
+
*/
|
|
89
|
+
async function registerSecondary(o, integrations) {
|
|
90
|
+
const fallback = readDefaultIntegration(o.root);
|
|
91
|
+
if (fallback === null || !integrations.includes(fallback))
|
|
92
|
+
return [];
|
|
93
|
+
const others = integrations.filter((i) => i !== fallback);
|
|
94
|
+
if (others.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const modes = scriptModes(o.root);
|
|
97
|
+
let failed;
|
|
98
|
+
for (const integration of others) {
|
|
99
|
+
try {
|
|
100
|
+
await specify(o.runner, o.root, ['integration', 'use', integration]);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
failed = { integration, error: e };
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await specify(o.runner, o.root, ['integration', 'use', fallback]);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
for (const [file, mode] of modes) {
|
|
112
|
+
if (existsSync(file) && (statSync(file).mode & 0o777) !== mode)
|
|
113
|
+
chmodSync(file, mode);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (failed) {
|
|
117
|
+
throw new SetupError(`impossible d'enregistrer les commandes de la méthode pour ${failed.integration} : ${failed.error.message} ; ${fallback} reste l'outil par défaut, relance update`);
|
|
118
|
+
}
|
|
119
|
+
return others;
|
|
120
|
+
}
|
|
121
|
+
async function isDirty(o) {
|
|
122
|
+
return ((await git(o, ['status', '--porcelain'])) ?? '').trim() !== '';
|
|
123
|
+
}
|
|
124
|
+
/** Étapes 3 à 6 communes à init et update, puis le relevé des anciens agents. */
|
|
125
|
+
async function common(o, mode) {
|
|
126
|
+
const { root } = o;
|
|
127
|
+
const migrated = registered(root, 'presets', 'boilerplate');
|
|
128
|
+
if (migrated)
|
|
129
|
+
await specify(o.runner, root, ['preset', 'remove', 'boilerplate']);
|
|
130
|
+
const installed = [];
|
|
131
|
+
const skipped = [];
|
|
132
|
+
for (const c of COMPONENTS) {
|
|
133
|
+
const present = registered(root, c.kind, c.id);
|
|
134
|
+
if (mode === 'init' && present) {
|
|
135
|
+
skipped.push(c.id);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const { args, input } = c.install(o.source, present);
|
|
139
|
+
try {
|
|
140
|
+
await specify(o.runner, root, args, input);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (o.source.kind === 'release' && c.id !== 'git' && e instanceof SpecifyError) {
|
|
144
|
+
const url = zipUrl(o.source, c.id);
|
|
145
|
+
throw new SetupError(`impossible d'installer ${c.id} ${o.source.version} depuis ${url} : ${e.message} ; relance la même commande une fois le réseau rétabli`);
|
|
146
|
+
}
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
installed.push(c.id);
|
|
150
|
+
}
|
|
151
|
+
const extYml = join(root, EXTENSIONS_YML);
|
|
152
|
+
writeIfChanged(extYml, applyGitHooks(existsSync(extYml) ? readFileSync(extYml, 'utf8') : ''));
|
|
153
|
+
let integrations;
|
|
154
|
+
try {
|
|
155
|
+
integrations = readIntegrations(root);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
throw new SetupError(`.specify/integration.json illisible : ${e.message}`);
|
|
159
|
+
}
|
|
160
|
+
// Seulement quand un composant vient d'être (ré)installé : un second init ne lance rien.
|
|
161
|
+
const registeredFor = installed.length > 0 ? await registerSecondary(o, integrations) : [];
|
|
162
|
+
const specs = readAgentSpecs(root);
|
|
163
|
+
const agents = [];
|
|
164
|
+
const agentsSkipped = [];
|
|
165
|
+
const noAgentGenerator = [];
|
|
166
|
+
for (const integration of integrations) {
|
|
167
|
+
const files = agentFiles(integration, specs);
|
|
168
|
+
if (files === null) {
|
|
169
|
+
noAgentGenerator.push(integration);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
writeIfChanged(join(root, f.path), f.content);
|
|
174
|
+
agents.push(f.path);
|
|
175
|
+
}
|
|
176
|
+
const names = skippedAgents(integration, specs);
|
|
177
|
+
if (names.length > 0)
|
|
178
|
+
agentsSkipped.push({ integration, names });
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
migrated,
|
|
182
|
+
noIntegration: integrations.length === 0,
|
|
183
|
+
installed,
|
|
184
|
+
skipped,
|
|
185
|
+
agents,
|
|
186
|
+
agentsSkipped,
|
|
187
|
+
noAgentGenerator,
|
|
188
|
+
registeredFor,
|
|
189
|
+
legacy: legacyAgents(root),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function init(o) {
|
|
193
|
+
const { root } = o;
|
|
194
|
+
if (!(await hasUv(o.runner)))
|
|
195
|
+
throw new SetupError(UV_MISSING);
|
|
196
|
+
// Le garde passe avant toute commande Spec Kit, migration du boilerplate comprise.
|
|
197
|
+
const previous = readInstalled(root);
|
|
198
|
+
if (previous && previous.version !== o.source.version) {
|
|
199
|
+
throw new SetupError(`Spec Kit a déjà la méthode en ${previous.version} (.specify/artefact.json) ; cet installeur installe ${o.source.version} : lance update pour changer de version`);
|
|
200
|
+
}
|
|
201
|
+
const noneInstalled = !registered(root, 'presets', 'artefact-sdd') && !registered(root, 'extensions', 'artefact-product');
|
|
202
|
+
const dirty = await isDirty(o);
|
|
203
|
+
const hadSpecify = existsSync(join(root, '.specify'));
|
|
204
|
+
if (!hadSpecify) {
|
|
205
|
+
const args = ['init', '--here', '--script', 'sh', '--non-interactive', '--ignore-agent-tools'];
|
|
206
|
+
args.push('--integration', o.integration ?? 'claude');
|
|
207
|
+
// Spec Kit 1.3.2 compte `.git` comme contenu : sans --force, il refuse un dossier non vide.
|
|
208
|
+
if (readdirSync(root).length > 0)
|
|
209
|
+
args.push('--force');
|
|
210
|
+
await specify(o.runner, root, args);
|
|
211
|
+
}
|
|
212
|
+
const done = await common(o, 'init');
|
|
213
|
+
const current = { version: o.source.version, source: o.source.kind };
|
|
214
|
+
const record = previous !== null || noneInstalled;
|
|
215
|
+
if (record)
|
|
216
|
+
writeInstalled(root, current);
|
|
217
|
+
let stack;
|
|
218
|
+
if (!existsSync(join(root, STACK))) {
|
|
219
|
+
stack = deduceStack(root, { examplesDir: defaultExamplesDir(), toolsVersion: o.toolsVersion, branches: await branches(o) });
|
|
220
|
+
writeIfChanged(join(root, STACK), stack.yaml);
|
|
221
|
+
}
|
|
222
|
+
let constitution = 'kept';
|
|
223
|
+
const constFile = join(root, CONSTITUTION);
|
|
224
|
+
if (!existsSync(constFile) || isBlankConstitution(readFileSync(constFile, 'utf8'))) {
|
|
225
|
+
const template = join(root, PRESET_TEMPLATE);
|
|
226
|
+
if (!existsSync(template)) {
|
|
227
|
+
throw new SetupError(`${PRESET_TEMPLATE} introuvable alors que le preset artefact-sdd est enregistré : lance update`);
|
|
228
|
+
}
|
|
229
|
+
writeIfChanged(constFile, baseConstitution(readFileSync(template, 'utf8'), basename(root)));
|
|
230
|
+
constitution = 'written';
|
|
231
|
+
}
|
|
232
|
+
// Spec Kit n'installe une intégration qu'à son init : sur un projet déjà initialisé, --integration ne fait rien.
|
|
233
|
+
let integrationIgnored;
|
|
234
|
+
if (hadSpecify && o.integration !== undefined) {
|
|
235
|
+
const installed = readIntegrations(root);
|
|
236
|
+
if (!installed.includes(o.integration))
|
|
237
|
+
integrationIgnored = { requested: o.integration, installed };
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
mode: 'init',
|
|
241
|
+
...done,
|
|
242
|
+
stack,
|
|
243
|
+
constitution,
|
|
244
|
+
dirty,
|
|
245
|
+
...(integrationIgnored ? { integrationIgnored } : {}),
|
|
246
|
+
version: { installed: record ? current : null, previous, downgrade: false }
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export async function update(o) {
|
|
250
|
+
if (!(await hasUv(o.runner)))
|
|
251
|
+
throw new SetupError(UV_MISSING);
|
|
252
|
+
if (!existsSync(join(o.root, '.specify')))
|
|
253
|
+
throw new SetupError('.specify/ absent : lance init');
|
|
254
|
+
const previous = readInstalled(o.root);
|
|
255
|
+
const dirty = await isDirty(o);
|
|
256
|
+
const done = await common(o, 'update');
|
|
257
|
+
const current = { version: o.source.version, source: o.source.kind };
|
|
258
|
+
writeInstalled(o.root, current);
|
|
259
|
+
const downgrade = previous !== null && compareVersions(previous.version, current.version) > 0;
|
|
260
|
+
return { mode: 'update', ...done, constitution: 'kept', dirty, version: { installed: current, previous, downgrade } };
|
|
261
|
+
}
|
package/dist/legacy.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const LEGACY_DIR = 'docs/agents';
|
|
5
|
+
/** SHA-256 des fichiers du boilerplate (commit b30bb7b). */
|
|
6
|
+
const BOILERPLATE = {
|
|
7
|
+
'designer.md': '01be6c6e3f1f86452bde42fb5117d288604f9757e0cf56c58a645fccc940bb4d',
|
|
8
|
+
'po.md': '2a78f6d5509157d26b866ad76de37abd3d075bb11dad63cc3eaae22188858696',
|
|
9
|
+
'relecteur-design.md': '3580a65a90e7fa3d8930213c304c18e74217a22e2b72efcb224902ddd3db95c2',
|
|
10
|
+
'relecteur-produit.md': '6907b77ab2d049462e9f06c5fbd4c64b901ccf9ba6d77f43a0a6150063c33b28',
|
|
11
|
+
'relecteur.md': '39825229e09467c002a60ac73f034a1ca440fbba7e53a9b6413d126e3618038d',
|
|
12
|
+
};
|
|
13
|
+
/** Dossiers de build, de cache ou de dépendances, sautés partout. */
|
|
14
|
+
const SKIPPED = new Set(['.git', 'node_modules', 'dist', '.next', '.nx', 'coverage', '.turbo', 'build', 'vendor']);
|
|
15
|
+
/**
|
|
16
|
+
* Chemins sautés : fichiers d'agent générés, l'ancien dossier lui-même, et ce que Spec Kit et l'installeur
|
|
17
|
+
* écrivent sous `.specify/`. `.specify/memory/` (la constitution du projet) est lu.
|
|
18
|
+
*/
|
|
19
|
+
const GENERATED = new Set([
|
|
20
|
+
'.claude/agents',
|
|
21
|
+
'.agents/agents',
|
|
22
|
+
LEGACY_DIR,
|
|
23
|
+
...['extensions', 'presets', 'templates', 'scripts', 'integrations', 'workflows'].map((d) => `.specify/${d}`),
|
|
24
|
+
]);
|
|
25
|
+
function findMentions(root, rel, out) {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(join(root, rel), { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
const code = e.code;
|
|
32
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
33
|
+
return;
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
38
|
+
if (e.isSymbolicLink())
|
|
39
|
+
continue;
|
|
40
|
+
if (e.isDirectory()) {
|
|
41
|
+
if (!SKIPPED.has(e.name) && !GENERATED.has(path))
|
|
42
|
+
findMentions(root, path, out);
|
|
43
|
+
}
|
|
44
|
+
else if (e.isFile() && e.name.endsWith('.md')) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(join(root, path), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
52
|
+
continue;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
if (text.includes(`${LEGACY_DIR}/`))
|
|
56
|
+
out.push(path);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Lit seulement : `docs/agents/` du projet n'est jamais modifié ni supprimé. */
|
|
61
|
+
export function legacyAgents(root) {
|
|
62
|
+
const dir = join(root, LEGACY_DIR);
|
|
63
|
+
const files = [];
|
|
64
|
+
if (existsSync(dir)) {
|
|
65
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
66
|
+
if (!e.isFile() || !e.name.endsWith('.md'))
|
|
67
|
+
continue;
|
|
68
|
+
const expected = BOILERPLATE[e.name];
|
|
69
|
+
const sha = createHash('sha256').update(readFileSync(join(dir, e.name))).digest('hex');
|
|
70
|
+
files.push({
|
|
71
|
+
path: `${LEGACY_DIR}/${e.name}`,
|
|
72
|
+
state: expected === undefined ? 'unknown' : expected === sha ? 'unchanged' : 'modified',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
76
|
+
}
|
|
77
|
+
const mentions = [];
|
|
78
|
+
findMentions(root, '', mentions);
|
|
79
|
+
mentions.sort();
|
|
80
|
+
return { files, mentions };
|
|
81
|
+
}
|
package/dist/report.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SPECIFY_VERSION } from "./specify.mjs";
|
|
2
|
+
const LEGACY_STATE = {
|
|
3
|
+
unchanged: 'identique au boilerplate, tu peux le supprimer',
|
|
4
|
+
/** Variante quand des fichiers citent encore docs/agents/. */
|
|
5
|
+
unchangedCited: 'identique au boilerplate : supprimable une fois les mentions ci-dessous mises à jour',
|
|
6
|
+
modified: 'modifié, à reporter dans les docs avant de supprimer le dossier',
|
|
7
|
+
unknown: 'inconnu du boilerplate, à reporter dans les docs avant de supprimer le dossier',
|
|
8
|
+
};
|
|
9
|
+
/** Dossier d'agents d'un fichier généré : `.claude/agents/x.md` → `.claude/agents`. */
|
|
10
|
+
const agentDir = (path) => path.split('/').slice(0, 2).join('/');
|
|
11
|
+
/** Le rapport d'init ou d'update, en français, pour la sortie standard. */
|
|
12
|
+
export function formatReport(r) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const list = (items) => items.join(', ');
|
|
15
|
+
out.push('Spec Kit');
|
|
16
|
+
if (r.installed.length > 0) {
|
|
17
|
+
out.push(` ${r.mode === 'update' ? 'Réinstallé / mis à jour' : 'Installé'} : ${list(r.installed)}`);
|
|
18
|
+
}
|
|
19
|
+
if (r.skipped.length > 0)
|
|
20
|
+
out.push(` Déjà installé : ${list(r.skipped)}`);
|
|
21
|
+
if (r.registeredFor.length > 0) {
|
|
22
|
+
out.push(` Commandes de la méthode enregistrées aussi pour : ${list(r.registeredFor)} (outil par défaut inchangé)`);
|
|
23
|
+
}
|
|
24
|
+
const { installed, previous, downgrade } = r.version;
|
|
25
|
+
if (installed === null) {
|
|
26
|
+
out.push(' Version installée inconnue : lance update pour aligner tous les composants');
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const now = `${installed.version} (${installed.source})`;
|
|
30
|
+
if (r.mode === 'init')
|
|
31
|
+
out.push(` Version : ${now}`);
|
|
32
|
+
else if (previous)
|
|
33
|
+
out.push(` Version : ${previous.version} → ${now}`);
|
|
34
|
+
else
|
|
35
|
+
out.push(` Version : ${now}, version précédente inconnue`);
|
|
36
|
+
if (downgrade && previous) {
|
|
37
|
+
out.push(` Attention : ${previous.version} était plus récente que ${installed.version}, l'installeur a remis ${installed.version}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (r.migrated) {
|
|
41
|
+
out.push('', 'Migration du boilerplate', ' preset `boilerplate` retiré ;', ' pour garder les templates de plan et de tâches propres à la stack, ajouter un preset local au-dessus', ' (`specify preset add --dev <chemin> --priority 5`, voir `examples/nx-preset/` de speckit-artefact) ;', ' remplacer `preset update boilerplate` dans `AGENTS.md` et les guides par `npx @artefact-ci/sdd update`.');
|
|
42
|
+
}
|
|
43
|
+
if (r.noIntegration || r.agents.length > 0 || r.noAgentGenerator.length > 0 || r.agentsSkipped.length > 0) {
|
|
44
|
+
out.push('', "Agents de relecture");
|
|
45
|
+
if (r.noIntegration) {
|
|
46
|
+
out.push(" aucune intégration trouvée dans .specify/integration.json : aucun fichier d'agent généré");
|
|
47
|
+
}
|
|
48
|
+
const byDir = new Map();
|
|
49
|
+
for (const path of r.agents)
|
|
50
|
+
byDir.set(agentDir(path), [...(byDir.get(agentDir(path)) ?? []), path]);
|
|
51
|
+
for (const [dir, paths] of byDir)
|
|
52
|
+
out.push(` ${dir} : ${paths.length} fichier(s) à jour`);
|
|
53
|
+
for (const name of r.noAgentGenerator) {
|
|
54
|
+
out.push(` ${name} : pas de générateur de fichiers d'agent, la relecture se fait en contexte neuf`);
|
|
55
|
+
}
|
|
56
|
+
for (const { integration, names } of r.agentsSkipped) {
|
|
57
|
+
out.push(` ${integration} : pas de fichier d'agent ${list(names)} pour cet outil : la commande fera ce travail en nouvelle session`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (r.stack) {
|
|
61
|
+
out.push('', ".specify/stack.yml déduit du dépôt : relis-le et corrige-le avant de t'en servir.");
|
|
62
|
+
out.push(` Stack détectée : ${r.stack.kind === 'unknown' ? 'aucune reconnue' : r.stack.kind}`);
|
|
63
|
+
if (r.stack.guessed.length > 0)
|
|
64
|
+
out.push(` Devinées : ${list(r.stack.guessed)}`);
|
|
65
|
+
if (r.stack.empty.length > 0)
|
|
66
|
+
out.push(` À compléter : ${list(r.stack.empty)}`);
|
|
67
|
+
}
|
|
68
|
+
out.push('', r.constitution === 'written'
|
|
69
|
+
? '.specify/memory/constitution.md écrit avec la base Artefact CI : ajoute les principes du projet.'
|
|
70
|
+
: '.specify/memory/constitution.md gardé tel quel.');
|
|
71
|
+
if (r.legacy.files.length > 0 || r.legacy.mentions.length > 0) {
|
|
72
|
+
out.push('', "Anciens agents (docs/agents/, jamais modifiés par l'installeur)");
|
|
73
|
+
const cited = r.legacy.mentions.length > 0;
|
|
74
|
+
for (const f of r.legacy.files) {
|
|
75
|
+
out.push(` ${f.path} : ${LEGACY_STATE[f.state === 'unchanged' && cited ? 'unchangedCited' : f.state]}`);
|
|
76
|
+
}
|
|
77
|
+
if (r.legacy.mentions.length > 0) {
|
|
78
|
+
out.push(` Citent encore docs/agents/ : ${list(r.legacy.mentions)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (r.integrationIgnored) {
|
|
82
|
+
const { requested, installed } = r.integrationIgnored;
|
|
83
|
+
const current = installed.length > 0 ? list(installed) : 'aucune intégration';
|
|
84
|
+
const specify = `uvx --from specify-cli==${SPECIFY_VERSION} specify integration`;
|
|
85
|
+
out.push('', `Attention : --integration ${requested} ignoré : Spec Kit était déjà installé ici avec ${current}.`, ` pour garder ${current} et ajouter ${requested} : ${specify} install ${requested} --script sh --force`, ` pour remplacer ${current} par ${requested} : ${specify} switch ${requested}`, " puis : npx @artefact-ci/sdd update (voir « Changer ou ajouter un outil » dans le README de speckit-artefact)");
|
|
86
|
+
}
|
|
87
|
+
if (r.dirty) {
|
|
88
|
+
out.push('', "Attention : le dépôt avait des changements non commités avant l'installation ; relis le diff avant de commiter.");
|
|
89
|
+
}
|
|
90
|
+
return `${out.join('\n')}\n`;
|
|
91
|
+
}
|
package/dist/source.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { SetupError } from "./errors.mjs";
|
|
5
|
+
export const RELEASE_BASE = 'https://github.com/artefact-ci/speckit-artefact/releases/download';
|
|
6
|
+
/** Release GitHub de `version` ; SDD_RELEASE_BASE_URL (réservée aux tests) remplace le préfixe de l'URL. */
|
|
7
|
+
export function releaseSource(version, env = process.env) {
|
|
8
|
+
const override = env['SDD_RELEASE_BASE_URL'];
|
|
9
|
+
const baseUrl = override ? override.replace(/\/+$/, '') : `${RELEASE_BASE}/v${version}`;
|
|
10
|
+
return { kind: 'release', version, baseUrl };
|
|
11
|
+
}
|
|
12
|
+
/** Clone de speckit-artefact : la version est `preset.version` du preset. */
|
|
13
|
+
export function devSource(dir) {
|
|
14
|
+
for (const file of ['extension/extension.yml', 'preset/preset.yml']) {
|
|
15
|
+
if (!existsSync(join(dir, file))) {
|
|
16
|
+
throw new SetupError(`${dir} n'est pas un clone de speckit-artefact : ${file} introuvable (passe --source <clone de speckit-artefact>)`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const file = join(dir, 'preset/preset.yml');
|
|
20
|
+
const version = parse(readFileSync(file, 'utf8'))?.preset?.version;
|
|
21
|
+
if (typeof version !== 'string' || version === '') {
|
|
22
|
+
throw new SetupError(`${file} : preset.version manque`);
|
|
23
|
+
}
|
|
24
|
+
return { kind: 'dev', version, dir };
|
|
25
|
+
}
|
|
26
|
+
export function zipUrl(s, id) {
|
|
27
|
+
return `${s.baseUrl}/${id}-${s.version}.zip`;
|
|
28
|
+
}
|
|
29
|
+
/** Arguments `specify` d'installation de `id` ; `input` est envoyé sur stdin. */
|
|
30
|
+
export function componentArgs(s, id, present) {
|
|
31
|
+
if (s.kind === 'release') {
|
|
32
|
+
const url = zipUrl(s, id);
|
|
33
|
+
if (id === 'artefact-product') {
|
|
34
|
+
// `extension add --from` demande « Continue with installation? [y/N] ».
|
|
35
|
+
return { args: ['extension', 'add', '--from', url, ...(present ? ['--force'] : [])], input: 'y\n' };
|
|
36
|
+
}
|
|
37
|
+
return { args: present ? ['preset', 'update', 'artefact-sdd', '--from', url] : ['preset', 'add', '--from', url] };
|
|
38
|
+
}
|
|
39
|
+
if (id === 'artefact-product') {
|
|
40
|
+
const dir = join(s.dir, 'extension');
|
|
41
|
+
return { args: ['extension', 'add', '--dev', dir, ...(present ? ['--force'] : [])] };
|
|
42
|
+
}
|
|
43
|
+
const dir = join(s.dir, 'preset');
|
|
44
|
+
return { args: present ? ['preset', 'update', 'artefact-sdd', '--dev', dir] : ['preset', 'add', '--dev', dir] };
|
|
45
|
+
}
|
package/dist/specify.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Version de Spec Kit (specify-cli) épinglée par l'installeur. */
|
|
3
|
+
export const SPECIFY_VERSION = '1.3.2';
|
|
4
|
+
/** Lance une commande sans shell. Ne rejette que si le processus ne peut pas démarrer. */
|
|
5
|
+
export const run = (cmd, args, opts) => new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
7
|
+
let stdout = '';
|
|
8
|
+
let stderr = '';
|
|
9
|
+
child.stdout.setEncoding('utf8').on('data', (d) => (stdout += d));
|
|
10
|
+
child.stderr.setEncoding('utf8').on('data', (d) => (stderr += d));
|
|
11
|
+
child.on('error', reject);
|
|
12
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
13
|
+
// Un EPIPE sur stdin (processus déjà terminé) ne doit pas faire tomber l'installeur.
|
|
14
|
+
child.stdin.on('error', () => { });
|
|
15
|
+
if (opts.input !== undefined)
|
|
16
|
+
child.stdin.write(opts.input);
|
|
17
|
+
child.stdin.end();
|
|
18
|
+
});
|
|
19
|
+
/** Spec Kit écrit ses erreurs sur stdout : le message en garde la fin, puis stderr. */
|
|
20
|
+
const TAIL_LINES = 20;
|
|
21
|
+
export class SpecifyError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
stdout;
|
|
24
|
+
stderr;
|
|
25
|
+
constructor(code, stderr, stdout = '') {
|
|
26
|
+
const tail = stdout.trimEnd().split('\n').slice(-TAIL_LINES).join('\n').trim();
|
|
27
|
+
const detail = [tail, stderr.trim()].filter(Boolean).join('\n');
|
|
28
|
+
super(`specify a échoué (code ${code})${detail ? ` :\n${detail}` : ''}`);
|
|
29
|
+
this.name = 'SpecifyError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.stdout = stdout;
|
|
32
|
+
this.stderr = stderr;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Exécute `specify …args` via uvx, à la version épinglée ; renvoie stdout. */
|
|
36
|
+
export async function specify(runner, cwd, args, input) {
|
|
37
|
+
const { code, stdout, stderr } = await runner('uvx', ['--from', `specify-cli==${SPECIFY_VERSION}`, 'specify', ...args], { cwd, ...(input !== undefined ? { input } : {}) });
|
|
38
|
+
if (code !== 0)
|
|
39
|
+
throw new SpecifyError(code, stderr, stdout);
|
|
40
|
+
return stdout;
|
|
41
|
+
}
|
|
42
|
+
export async function hasUv(runner) {
|
|
43
|
+
try {
|
|
44
|
+
return (await runner('uvx', ['--version'], { cwd: process.cwd() })).code === 0;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/stack.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
|
+
const TODO = '# à compléter';
|
|
6
|
+
/** Les exemples sont copiés dans `dist/examples/` au build ; depuis `src/` (dev, tests), on lit ceux du dépôt. */
|
|
7
|
+
export function defaultExamplesDir() {
|
|
8
|
+
const built = new URL('./examples/', import.meta.url);
|
|
9
|
+
return fileURLToPath(existsSync(built) ? built : new URL('../../../examples/stack/', import.meta.url));
|
|
10
|
+
}
|
|
11
|
+
const RUNNERS = {
|
|
12
|
+
bun: { run: 'bun run', dlx: 'bunx' },
|
|
13
|
+
pnpm: { run: 'pnpm', dlx: 'pnpm dlx' },
|
|
14
|
+
// yarn 1 n'a pas de dlx : npx marche partout où yarn tourne.
|
|
15
|
+
yarn: { run: 'yarn', dlx: 'npx -y' },
|
|
16
|
+
npm: { run: 'npm run', dlx: 'npx -y' },
|
|
17
|
+
};
|
|
18
|
+
const EXEC = { bun: 'bunx ', pnpm: 'pnpm ', yarn: 'yarn ', npm: 'npx ' };
|
|
19
|
+
const detectRunner = (has) => has('bun.lock') || has('bun.lockb') ? 'bun'
|
|
20
|
+
: has('pnpm-lock.yaml') ? 'pnpm'
|
|
21
|
+
: has('yarn.lock') ? 'yarn'
|
|
22
|
+
: 'npm';
|
|
23
|
+
const detectKind = (root, has) => {
|
|
24
|
+
if (has('nx.json'))
|
|
25
|
+
return 'nx';
|
|
26
|
+
if (['js', 'mjs', 'cjs', 'ts', 'mts'].some((e) => has(`next.config.${e}`)))
|
|
27
|
+
return 'nextjs';
|
|
28
|
+
if (has('bun.lock') || has('bun.lockb'))
|
|
29
|
+
return 'bun-hono';
|
|
30
|
+
return 'unknown';
|
|
31
|
+
};
|
|
32
|
+
const readScripts = (root) => {
|
|
33
|
+
try {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
35
|
+
return pkg.scripts ?? {};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const scalar = (v) => stringify(v, { lineWidth: 0, singleQuote: true }).trimEnd();
|
|
42
|
+
const flow = (v) => stringify(v, { collectionStyle: 'flow', flowCollectionPadding: false, singleQuote: true, lineWidth: 0 }).trimEnd();
|
|
43
|
+
/**
|
|
44
|
+
* Déduit le texte de `.specify/stack.yml` d'un dépôt, sans rien écrire. Part de l'exemple le plus proche
|
|
45
|
+
* (`examples/stack/`), garde ce que le dépôt confirme, laisse le reste vide avec `# à compléter`.
|
|
46
|
+
*/
|
|
47
|
+
export function deduceStack(root, opts) {
|
|
48
|
+
const has = (p) => existsSync(join(root, p));
|
|
49
|
+
const kind = detectKind(root, has);
|
|
50
|
+
const runner = detectRunner(has);
|
|
51
|
+
const scripts = readScripts(root);
|
|
52
|
+
const run = RUNNERS[runner].run;
|
|
53
|
+
const dlx = RUNNERS[runner].dlx;
|
|
54
|
+
const ex = kind === 'unknown' ? {} : parse(readFileSync(join(opts.examplesDir, `${kind}.yml`), 'utf8'));
|
|
55
|
+
/** `pnpm exec x` / `pnpm x` de l'exemple, réécrits pour le runner du projet. */
|
|
56
|
+
const swap = (cmd) => {
|
|
57
|
+
if (runner === 'pnpm')
|
|
58
|
+
return cmd;
|
|
59
|
+
if (cmd.startsWith('pnpm exec '))
|
|
60
|
+
return EXEC[runner] + cmd.slice('pnpm exec '.length);
|
|
61
|
+
if (cmd.startsWith('pnpm '))
|
|
62
|
+
return EXEC[runner] + cmd.slice('pnpm '.length);
|
|
63
|
+
return cmd;
|
|
64
|
+
};
|
|
65
|
+
const guessed = new Set();
|
|
66
|
+
const empty = new Set();
|
|
67
|
+
const lines = [`# Déduit par @artefact-ci/sdd ${opts.toolsVersion} : relis chaque clé (voir docs/stack.md).`, 'version: 1'];
|
|
68
|
+
/** Une clé : valeur, ou vide + `# à compléter`. */
|
|
69
|
+
const put = (indent, path, value, comment) => {
|
|
70
|
+
const key = path.split('.').at(-1);
|
|
71
|
+
const pad = ' '.repeat(indent);
|
|
72
|
+
if (value === undefined || value === '') {
|
|
73
|
+
empty.add(path);
|
|
74
|
+
lines.push(`${pad}${key}: ${TODO}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
lines.push(`${pad}${key}: ${value}${comment ? ` ${comment}` : ''}`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const putList = (path, values) => {
|
|
81
|
+
if (values.length === 0)
|
|
82
|
+
return void put(0, path, undefined);
|
|
83
|
+
lines.push(`${path}:`, ...values.map((v) => ` - ${scalar(v)}`));
|
|
84
|
+
};
|
|
85
|
+
// branches : base = dev, sinon la branche par défaut (origin/HEAD, puis main, puis master), jamais la
|
|
86
|
+
// branche courante ; production = main si présente et différente de base. Sinon vide, à compléter.
|
|
87
|
+
const names = new Set(opts.branches.names);
|
|
88
|
+
const def = opts.branches.defaultBranch;
|
|
89
|
+
const base = names.has('dev') ? 'dev' : [def, 'main', 'master'].find((b) => b !== undefined && names.has(b));
|
|
90
|
+
const production = names.has('main') && base !== 'main' ? 'main' : undefined;
|
|
91
|
+
lines.push('branches:');
|
|
92
|
+
put(2, 'branches.base', base === undefined ? undefined : scalar(base));
|
|
93
|
+
put(2, 'branches.production', production);
|
|
94
|
+
if (base !== undefined)
|
|
95
|
+
guessed.add('branches.base');
|
|
96
|
+
if (production !== undefined)
|
|
97
|
+
guessed.add('branches.production');
|
|
98
|
+
const docs = (ex.docs ?? ['AGENTS.md']).filter(has);
|
|
99
|
+
putList('docs', docs);
|
|
100
|
+
if (docs.length > 0)
|
|
101
|
+
guessed.add('docs');
|
|
102
|
+
// commandes
|
|
103
|
+
lines.push('commands:');
|
|
104
|
+
const exampleCmd = (name) => {
|
|
105
|
+
const c = ex.commands?.[name];
|
|
106
|
+
return c === undefined ? undefined : swap(c);
|
|
107
|
+
};
|
|
108
|
+
const cmds = [];
|
|
109
|
+
if (kind === 'nx') {
|
|
110
|
+
cmds.push(['test', exampleCmd('test')], ['affected', exampleCmd('affected')], ['fresh', exampleCmd('fresh')]);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const t = scripts['test'];
|
|
114
|
+
const test = t === undefined ? undefined : t === 'bun test' ? 'bun test {file}' : runner === 'npm' ? 'npm run test -- {file}' : `${run} test {file}`;
|
|
115
|
+
const parts = ['lint', 'typecheck', 'test'].filter((s) => scripts[s] !== undefined).map((s) => `${run} ${s}`);
|
|
116
|
+
cmds.push(['test', test], ['affected', t === undefined ? undefined : parts.join(' && ')]);
|
|
117
|
+
}
|
|
118
|
+
cmds.push(['check', scripts['check'] === undefined ? undefined : `${run} check`]);
|
|
119
|
+
cmds.push(['trace', `${dlx} @artefact-ci/spec-trace@${opts.toolsVersion}`]);
|
|
120
|
+
for (const [name, value] of cmds) {
|
|
121
|
+
put(2, `commands.${name}`, value === undefined ? undefined : scalar(value));
|
|
122
|
+
if (value !== undefined)
|
|
123
|
+
guessed.add(`commands.${name}`);
|
|
124
|
+
}
|
|
125
|
+
// tests, shared
|
|
126
|
+
if (ex.tests) {
|
|
127
|
+
lines.push('tests:');
|
|
128
|
+
if (ex.tests.globs)
|
|
129
|
+
(put(2, 'tests.globs', flow(ex.tests.globs)), guessed.add('tests.globs'));
|
|
130
|
+
if (ex.tests.needs)
|
|
131
|
+
(put(2, 'tests.needs', flow(ex.tests.needs)), guessed.add('tests.needs'));
|
|
132
|
+
}
|
|
133
|
+
if (ex.shared) {
|
|
134
|
+
const shared = ex.shared.filter(has);
|
|
135
|
+
putList('shared', shared);
|
|
136
|
+
if (shared.length > 0)
|
|
137
|
+
guessed.add('shared');
|
|
138
|
+
}
|
|
139
|
+
// flags : livrer derrière un feature flag est une décision de l'équipe, jamais devinée.
|
|
140
|
+
put(0, 'flags', undefined);
|
|
141
|
+
// ui : objet dès qu'un front est détecté
|
|
142
|
+
guessed.add('ui');
|
|
143
|
+
if (kind === 'nx' || kind === 'nextjs') {
|
|
144
|
+
const ui = ex.ui ?? {};
|
|
145
|
+
lines.push('ui:');
|
|
146
|
+
put(2, 'ui.components', ui.components && has(ui.components) ? scalar(ui.components) : undefined);
|
|
147
|
+
if (ui.storybook !== undefined)
|
|
148
|
+
put(2, 'ui.storybook', String(ui.storybook));
|
|
149
|
+
put(2, 'ui.theme', ui.theme && has(ui.theme) ? scalar(ui.theme) : undefined);
|
|
150
|
+
if (ui.preview)
|
|
151
|
+
put(2, 'ui.preview', scalar(swap(ui.preview)), '# http://localhost:6006');
|
|
152
|
+
put(2, 'ui.prototype', scalar(scripts['prototype'] !== undefined ? `${run} prototype` : `${dlx} @artefact-ci/prototype@${opts.toolsVersion}`));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
lines.push('ui: null # pas de front : /speckit-design n\'écrit que la partie non visuelle, /speckit-prototype s\'arrête');
|
|
156
|
+
}
|
|
157
|
+
// e2e : seulement si le dossier des parcours existe
|
|
158
|
+
guessed.add('e2e');
|
|
159
|
+
if (ex.e2e && has(ex.e2e.dir)) {
|
|
160
|
+
lines.push('e2e:');
|
|
161
|
+
put(2, 'e2e.dir', scalar(ex.e2e.dir));
|
|
162
|
+
if (ex.e2e.command)
|
|
163
|
+
put(2, 'e2e.command', scalar(swap(ex.e2e.command)));
|
|
164
|
+
if (ex.e2e.fresh)
|
|
165
|
+
put(2, 'e2e.fresh', scalar(swap(ex.e2e.fresh)));
|
|
166
|
+
if (ex.e2e.setup)
|
|
167
|
+
put(2, 'e2e.setup', scalar(swap(ex.e2e.setup)));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
lines.push('e2e: null');
|
|
171
|
+
}
|
|
172
|
+
return { yaml: `${lines.join('\n')}\n`, kind, guessed: [...guessed].sort(), empty: [...empty].sort() };
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,34 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@artefact-ci/sdd",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Installeur de la méthode Artefact CI pour Spec Kit (preset artefact-sdd, extension artefact-product)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/artefact-ci/speckit-artefact.git",
|
|
9
|
+
"directory": "packages/sdd"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"artefact-sdd": "dist/cli.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=24.11"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"yaml": "^2.9.1"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/examples && cp ../../examples/stack/*.yml dist/examples/",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "node --test 'src/*.test.mts'",
|
|
32
|
+
"test:e2e": "pnpm build && node --test test/e2e.test.mts"
|
|
33
|
+
}
|
|
34
|
+
}
|