@forgeailab/spark 0.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 +97 -0
- package/package.json +34 -0
- package/src/cli.ts +47 -0
- package/src/commands/add.ts +367 -0
- package/src/commands/check.ts +166 -0
- package/src/commands/info.ts +98 -0
- package/src/commands/list.ts +79 -0
- package/src/commands/preset.ts +49 -0
- package/src/config.ts +33 -0
- package/src/io/board.ts +203 -0
- package/src/io/deps.ts +54 -0
- package/src/io/env.ts +58 -0
- package/src/io/files.ts +259 -0
- package/src/io/paths.ts +57 -0
- package/src/io/registry.ts +193 -0
- package/src/io/skills.ts +128 -0
- package/src/io/state.ts +59 -0
- package/src/resolver.ts +381 -0
- package/src/runtime-package.ts +132 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# `spark` CLI
|
|
2
|
+
|
|
3
|
+
The `spark` CLI manages feature packs in a project scaffolded with `create-spark`. Run it from your project root.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
`spark` is invoked through Bun. Inside this monorepo:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun packages/spark/src/cli.ts <command>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
After publishing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bunx spark <command>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A scaffolded project has `spark` available in scripts; in dev you can also alias it locally.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
### `list`
|
|
24
|
+
|
|
25
|
+
Show all known packs grouped by category. Marks installed packs from `.spark/state.json` and shows scaffold compatibility.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
spark list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `info <pack>`
|
|
32
|
+
|
|
33
|
+
Print everything the install would do — files touched, env vars added, deps installed, skills shipped, tasks seeded. Same surface as `add --dry-run` for a single pack.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
spark info payments-stripe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For **hybrid** packs (those with a `[runtime_package]` block in their manifest), `info` also prints:
|
|
40
|
+
|
|
41
|
+
- `Install mode: hybrid` (vs `copy` for the rest)
|
|
42
|
+
- `Runtime helper: <package> (range <X>, resolved <Y>)` — the npm name + manifest version range + the version currently installed in the project's `package.json` (or `not installed`).
|
|
43
|
+
|
|
44
|
+
For copy packs `info` prints `Install mode: copy` and omits the helper line.
|
|
45
|
+
|
|
46
|
+
### `add <pack...> [--dry-run]`
|
|
47
|
+
|
|
48
|
+
Resolve the requested packs against the registry + installed set + active scaffold, then apply. Idempotent: running it twice with the same args is a no-op.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
spark add db-sqlite ui-shadcn
|
|
52
|
+
spark add payments-stripe --dry-run
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The resolver enforces:
|
|
56
|
+
|
|
57
|
+
- **Closed capability enums.** `requires` / `provides` / `conflicts` reference pack-capability tags; `requires_runtime` references template-capability tags. Unknown values are rejected.
|
|
58
|
+
- **Exclusivity.** Capabilities classified exclusive (`db`, `auth`, `payments`, `ui-kit`, `sync`) reject double-installs. Non-exclusive caps (`ai-sdk`, `analytics`, `email`, `blob-storage`, `e2e`, `deploy-target`, `local-runtime`) coexist.
|
|
59
|
+
- **Scaffold compatibility.** A pack with `compatible_scaffolds = ["nextjs"]` refuses to install on a `vite-react` project.
|
|
60
|
+
|
|
61
|
+
**Hybrid pack install.** When a pack declares `[runtime_package]`, the CLI adds the helper package to the same `bun add` batch as the pack's explicit runtime deps. Two resolution modes:
|
|
62
|
+
|
|
63
|
+
- **Dev mode** — `SPARK_ROOT` is set and `${SPARK_ROOT}/libs/<helper>/` exists. The helper is linked via `file:` to the workspace path. Used by the reference app and `/tmp/spark-validate` smoke installs.
|
|
64
|
+
- **Published mode** — otherwise. The helper is installed by `<npm-name>@<range>` from the manifest's `[runtime_package].version`.
|
|
65
|
+
|
|
66
|
+
Either way the pack's `[dependencies].runtime` array MUST NOT also list the helper — the CLI handles it implicitly (see Decision 6 in the runtime-packages design doc).
|
|
67
|
+
|
|
68
|
+
### `preset <name>`
|
|
69
|
+
|
|
70
|
+
Apply a named bundle from `presets/<name>.toml`. Refuses if the active scaffold is not in the preset's `compatible_scaffolds`.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
spark preset lean-saas
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `check`
|
|
77
|
+
|
|
78
|
+
Audit `.spark/state.json` against the filesystem. Reports missing files, missing env vars in `.env.local`, deleted seeded tasks. For each installed **hybrid** pack, also verifies the helper package is still listed in the consumer's `package.json` (under `dependencies` or `devDependencies`) — surfaces a `drift: helper packages` section when a helper has been removed manually. **Does not repair.**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
spark check
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What's not here
|
|
85
|
+
|
|
86
|
+
- No `remove` / `uninstall` / `update`. v1 trusts git for reversal; `git revert` undoes a pack install.
|
|
87
|
+
- No `post_install` / shell hooks in pack manifests — installs are fully declarative.
|
|
88
|
+
|
|
89
|
+
## How state works
|
|
90
|
+
|
|
91
|
+
`.spark/state.json` records, per installed pack: files written, env vars appended, tasks seeded. The CLI never deletes project files based on this — its sole purpose is drift detection via `check`.
|
|
92
|
+
|
|
93
|
+
## See also
|
|
94
|
+
|
|
95
|
+
- `docs/pack-spec.md` — writing a new pack
|
|
96
|
+
- `templates/README.md` — registering a new scaffold template
|
|
97
|
+
- `docs/spec/AGENTS.md` — spec-driven workflow contract
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeailab/spark",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for managing feature packs in an spark-scaffolded project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"spark": "./src/cli.ts"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@forgeailab/spark-schema": "workspace:*",
|
|
22
|
+
"@forgeailab/spark-state": "workspace:*",
|
|
23
|
+
"@forgeailab/spark-skill-utils": "workspace:*",
|
|
24
|
+
"@forgeailab/spark-board": "workspace:*",
|
|
25
|
+
"@clack/prompts": "latest",
|
|
26
|
+
"citty": "latest",
|
|
27
|
+
"picocolors": "latest",
|
|
28
|
+
"smol-toml": "latest"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"bun-types": "latest",
|
|
32
|
+
"typescript": "latest"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from 'citty';
|
|
3
|
+
import { addCommand } from './commands/add.ts';
|
|
4
|
+
import { checkCommand } from './commands/check.ts';
|
|
5
|
+
import { infoCommand } from './commands/info.ts';
|
|
6
|
+
import { listCommand } from './commands/list.ts';
|
|
7
|
+
import { presetCommand } from './commands/preset.ts';
|
|
8
|
+
|
|
9
|
+
const subCommands = {
|
|
10
|
+
list: listCommand,
|
|
11
|
+
info: infoCommand,
|
|
12
|
+
check: checkCommand,
|
|
13
|
+
add: addCommand,
|
|
14
|
+
preset: presetCommand,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mainCommand = defineCommand({
|
|
18
|
+
meta: {
|
|
19
|
+
name: 'spark',
|
|
20
|
+
description: 'Composable spark scaffold pack manager',
|
|
21
|
+
},
|
|
22
|
+
subCommands,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function guardUnknownSubcommand(argv: readonly string[]): void {
|
|
26
|
+
const command = argv.find((arg) => !arg.startsWith('-'));
|
|
27
|
+
if (!command || Object.hasOwn(subCommands, command)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (command === 'remove' || command === 'uninstall' || command === 'update') {
|
|
32
|
+
console.error(
|
|
33
|
+
`unknown subcommand "${command}". spark does not support uninstall/update in v1; use git revert to undo a pack install.`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.error(`unknown subcommand "${command}".`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (import.meta.main) {
|
|
43
|
+
guardUnknownSubcommand(process.argv.slice(2));
|
|
44
|
+
await runMain(mainCommand);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { mainCommand };
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { confirm, isCancel } from '@clack/prompts';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import type { StateInstalledPack } from '@forgeailab/spark-schema';
|
|
5
|
+
import { readConfig, type AppSkillsConfig } from '../config.ts';
|
|
6
|
+
import {
|
|
7
|
+
resolveInstallPlan,
|
|
8
|
+
type InstallPlan,
|
|
9
|
+
type ResolverError,
|
|
10
|
+
type ResolverRegistry,
|
|
11
|
+
type ResolverTemplate,
|
|
12
|
+
} from '../resolver.ts';
|
|
13
|
+
import { applyFileOperations, preflightFileOperations, type FileApplyRecord } from '../io/files.ts';
|
|
14
|
+
import { appendEnvVars } from '../io/env.ts';
|
|
15
|
+
import { installDependencies, type DependencyRunner } from '../io/deps.ts';
|
|
16
|
+
import { readRegistry, type Registry } from '../io/registry.ts';
|
|
17
|
+
import { addInstalledPack, installedPackNames, readState, writeState } from '../io/state.ts';
|
|
18
|
+
import { seedBoardTasks } from '../io/board.ts';
|
|
19
|
+
import { copyPackSkills } from '../io/skills.ts';
|
|
20
|
+
import { assertRuntimeHelperNotRedeclared, resolveRuntimeHelper } from '../runtime-package.ts';
|
|
21
|
+
|
|
22
|
+
type AddOutput = Pick<Console, 'log' | 'error'>;
|
|
23
|
+
|
|
24
|
+
export type AddOptions = {
|
|
25
|
+
projectRoot?: string;
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
yes?: boolean;
|
|
28
|
+
registry?: Registry;
|
|
29
|
+
config?: AppSkillsConfig;
|
|
30
|
+
dependencyRunner?: DependencyRunner;
|
|
31
|
+
output?: AddOutput;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AddResult = {
|
|
35
|
+
status: 'already-installed' | 'dry-run' | 'installed';
|
|
36
|
+
plan: InstallPlan;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function formatResolverError(error: ResolverError): string {
|
|
40
|
+
if (error.type === 'missing-capability') {
|
|
41
|
+
const providers =
|
|
42
|
+
error.providers.length > 0 ? ` Providers: ${error.providers.join(', ')}` : ' No providers found.';
|
|
43
|
+
return `${error.pack} requires missing capability "${error.capability}".${providers}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (error.type === 'exclusive-conflict') {
|
|
47
|
+
const suggestion =
|
|
48
|
+
error.source === 'exclusive-capability'
|
|
49
|
+
? ' Use git reset or revert the existing pack install before choosing an alternative.'
|
|
50
|
+
: '';
|
|
51
|
+
return `${error.packs[0]} conflicts with ${error.packs[1]} on capability "${error.capability}".${suggestion}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error.type === 'scaffold-incompat') {
|
|
55
|
+
return `${error.pack} is not compatible with scaffold "${error.activeScaffold}". Compatible scaffolds: ${error.compatibleScaffolds.join(', ')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (error.type === 'runtime-incompat') {
|
|
59
|
+
return `${error.pack} requires runtime "${error.missingRuntime}", but scaffold "${error.activeScaffold}" provides: ${error.providedRuntime.join(', ')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (error.type === 'circular') {
|
|
63
|
+
return `Circular pack dependency detected: ${error.cycle.join(' -> ')}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `Unknown pack: ${error.pack}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function packRuntimeDependencies(plan: InstallPlan): Promise<string[]> {
|
|
70
|
+
const dependencies: string[] = [];
|
|
71
|
+
|
|
72
|
+
for (const pack of plan.packs) {
|
|
73
|
+
assertRuntimeHelperNotRedeclared(pack.name, pack.manifest);
|
|
74
|
+
dependencies.push(...(pack.manifest.dependencies?.runtime ?? []));
|
|
75
|
+
|
|
76
|
+
const helper = await resolveRuntimeHelper(pack.manifest);
|
|
77
|
+
if (helper) {
|
|
78
|
+
dependencies.push(helper);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return dependencies;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function packDevDependencies(plan: InstallPlan): string[] {
|
|
86
|
+
return plan.packs.flatMap((pack) => pack.manifest.dependencies?.dev ?? []);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderPlan(
|
|
90
|
+
plan: InstallPlan,
|
|
91
|
+
runtimeDependencies: readonly string[],
|
|
92
|
+
devDependencies: readonly string[],
|
|
93
|
+
): string {
|
|
94
|
+
if (plan.packs.length === 0) {
|
|
95
|
+
return 'No packs to install.';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines = ['Install plan:'];
|
|
99
|
+
for (const pack of plan.packs) {
|
|
100
|
+
lines.push(`- ${pack.name}`);
|
|
101
|
+
for (const file of pack.manifest.files ?? []) {
|
|
102
|
+
lines.push(` ${file.mode}: ${file.from} -> ${file.to}`);
|
|
103
|
+
}
|
|
104
|
+
for (const key of pack.manifest.env?.required ?? []) {
|
|
105
|
+
lines.push(` env: ${key}`);
|
|
106
|
+
}
|
|
107
|
+
for (const skill of pack.manifest.skills?.copy ?? []) {
|
|
108
|
+
lines.push(` skill: ${skill}`);
|
|
109
|
+
}
|
|
110
|
+
if (pack.manifest.tasks?.file) {
|
|
111
|
+
lines.push(` tasks: ${pack.manifest.tasks.file}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (runtimeDependencies.length > 0) {
|
|
116
|
+
lines.push(`runtime deps: ${[...new Set(runtimeDependencies)].sort().join(', ')}`);
|
|
117
|
+
}
|
|
118
|
+
if (devDependencies.length > 0) {
|
|
119
|
+
lines.push(`dev deps: ${[...new Set(devDependencies)].sort().join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function activeTemplateFromRegistry(config: AppSkillsConfig, registry: Registry): ResolverTemplate {
|
|
126
|
+
const template = registry.templates.get(config.template);
|
|
127
|
+
if (!template) {
|
|
128
|
+
throw new Error(`Template "${config.template}" is not registered in the spark template registry.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (template.manifest.status === 'planned') {
|
|
132
|
+
throw new Error(`Template "${config.template}" is planned; template not yet implemented.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return template.manifest;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function preflightPlanFiles(
|
|
139
|
+
projectRoot: string,
|
|
140
|
+
registry: ResolverRegistry,
|
|
141
|
+
config: AppSkillsConfig,
|
|
142
|
+
plan: InstallPlan,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const createTargets = new Map<string, string>();
|
|
145
|
+
|
|
146
|
+
for (const pack of plan.packs) {
|
|
147
|
+
const entry = registry.packs.get(pack.name);
|
|
148
|
+
if (!entry?.dir) {
|
|
149
|
+
throw new Error(`Pack "${pack.name}" has no registry directory.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const operation of pack.manifest.files ?? []) {
|
|
153
|
+
if (operation.mode !== 'create') {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const existingPack = createTargets.get(operation.to);
|
|
158
|
+
if (existingPack) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`create mode conflict: ${pack.name} and ${existingPack} both target ${operation.to}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
createTargets.set(operation.to, pack.name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await preflightFileOperations({
|
|
167
|
+
projectRoot,
|
|
168
|
+
packRoot: entry.dir,
|
|
169
|
+
packName: pack.name,
|
|
170
|
+
config,
|
|
171
|
+
operations: pack.manifest.files ?? [],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stateEntryForPack(
|
|
177
|
+
packName: string,
|
|
178
|
+
version: string,
|
|
179
|
+
fileRecords: readonly FileApplyRecord[],
|
|
180
|
+
envVars: readonly string[],
|
|
181
|
+
taskIds: readonly string[],
|
|
182
|
+
skillFiles: readonly string[],
|
|
183
|
+
touchedEnvFiles: readonly string[],
|
|
184
|
+
): StateInstalledPack {
|
|
185
|
+
const fileTargets = [
|
|
186
|
+
...fileRecords.map((record) => record.to),
|
|
187
|
+
...skillFiles,
|
|
188
|
+
...touchedEnvFiles,
|
|
189
|
+
...(taskIds.length > 0 ? ['.ai/board.md'] : []),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
name: packName,
|
|
194
|
+
version,
|
|
195
|
+
files: [...new Set(fileTargets)].sort(),
|
|
196
|
+
appended_blocks: fileRecords
|
|
197
|
+
.filter((record) => record.marker)
|
|
198
|
+
.map((record) => ({
|
|
199
|
+
to: record.to,
|
|
200
|
+
marker: record.marker ?? '',
|
|
201
|
+
content_hash: record.contentHash,
|
|
202
|
+
})),
|
|
203
|
+
env: [...new Set(envVars)].sort(),
|
|
204
|
+
tasks: [...new Set(taskIds)].sort(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function runAdd(requestedPacks: readonly string[], options: AddOptions = {}): Promise<AddResult> {
|
|
209
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
210
|
+
const output = options.output ?? console;
|
|
211
|
+
const requested = requestedPacks.filter((pack) => pack.length > 0);
|
|
212
|
+
|
|
213
|
+
if (requested.length === 0) {
|
|
214
|
+
throw new Error('add requires at least one pack name');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const [config, registry, state] = await Promise.all([
|
|
218
|
+
options.config ? Promise.resolve(options.config) : readConfig(projectRoot),
|
|
219
|
+
options.registry ? Promise.resolve(options.registry) : readRegistry(projectRoot),
|
|
220
|
+
readState(projectRoot),
|
|
221
|
+
]);
|
|
222
|
+
const activeTemplate = activeTemplateFromRegistry(config, registry);
|
|
223
|
+
const resolution = resolveInstallPlan(requested, installedPackNames(state), registry, activeTemplate);
|
|
224
|
+
|
|
225
|
+
if (!resolution.ok) {
|
|
226
|
+
throw new Error(formatResolverError(resolution.error));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const plan = resolution.data;
|
|
230
|
+
if (plan.packs.length === 0) {
|
|
231
|
+
output.log(`${requested.join(', ')} already installed`);
|
|
232
|
+
return {
|
|
233
|
+
status: 'already-installed',
|
|
234
|
+
plan,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const runtimeDependencies = await packRuntimeDependencies(plan);
|
|
239
|
+
const devDependencies = packDevDependencies(plan);
|
|
240
|
+
|
|
241
|
+
if (options.dryRun) {
|
|
242
|
+
output.log(renderPlan(plan, runtimeDependencies, devDependencies));
|
|
243
|
+
return {
|
|
244
|
+
status: 'dry-run',
|
|
245
|
+
plan,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!options.yes) {
|
|
250
|
+
const accepted = await confirm({
|
|
251
|
+
message: `Install ${plan.packs.map((pack) => pack.name).join(', ')}?`,
|
|
252
|
+
initialValue: false,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (isCancel(accepted) || !accepted) {
|
|
256
|
+
throw new Error('Install cancelled');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await preflightPlanFiles(projectRoot, registry, config, plan);
|
|
261
|
+
|
|
262
|
+
let nextState = state;
|
|
263
|
+
const stateEntries: StateInstalledPack[] = [];
|
|
264
|
+
for (const pack of plan.packs) {
|
|
265
|
+
const entry = registry.packs.get(pack.name);
|
|
266
|
+
if (!entry?.dir) {
|
|
267
|
+
throw new Error(`Pack "${pack.name}" has no registry directory.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const fileRecords = await applyFileOperations({
|
|
271
|
+
projectRoot,
|
|
272
|
+
packRoot: entry.dir,
|
|
273
|
+
packName: pack.name,
|
|
274
|
+
config,
|
|
275
|
+
operations: pack.manifest.files ?? [],
|
|
276
|
+
});
|
|
277
|
+
const envVars = pack.manifest.env?.required ?? [];
|
|
278
|
+
const envResults = await appendEnvVars(projectRoot, envVars);
|
|
279
|
+
const touchedEnvFiles = envResults
|
|
280
|
+
.filter((result) => result.added.length > 0)
|
|
281
|
+
.map((result) => result.file);
|
|
282
|
+
const taskIds = await seedBoardTasks(
|
|
283
|
+
projectRoot,
|
|
284
|
+
pack.name,
|
|
285
|
+
entry.dir,
|
|
286
|
+
pack.manifest.tasks?.file,
|
|
287
|
+
);
|
|
288
|
+
const skillFiles = await copyPackSkills(
|
|
289
|
+
projectRoot,
|
|
290
|
+
entry.dir,
|
|
291
|
+
pack.manifest.skills?.copy ?? [],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
stateEntries.push(
|
|
295
|
+
stateEntryForPack(
|
|
296
|
+
pack.name,
|
|
297
|
+
pack.manifest.version,
|
|
298
|
+
fileRecords,
|
|
299
|
+
envVars,
|
|
300
|
+
taskIds,
|
|
301
|
+
skillFiles,
|
|
302
|
+
touchedEnvFiles,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await installDependencies(
|
|
308
|
+
projectRoot,
|
|
309
|
+
runtimeDependencies,
|
|
310
|
+
devDependencies,
|
|
311
|
+
options.dependencyRunner,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
for (const entry of stateEntries) {
|
|
315
|
+
nextState = addInstalledPack(nextState, entry);
|
|
316
|
+
}
|
|
317
|
+
await writeState(projectRoot, nextState);
|
|
318
|
+
|
|
319
|
+
output.log(pc.green(`Installed ${plan.packs.map((pack) => pack.name).join(', ')}`));
|
|
320
|
+
return {
|
|
321
|
+
status: 'installed',
|
|
322
|
+
plan,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseAddArgs(rawArgs: readonly string[]): { packs: string[]; dryRun: boolean; yes: boolean } {
|
|
327
|
+
const packs: string[] = [];
|
|
328
|
+
let dryRun = false;
|
|
329
|
+
let yes = false;
|
|
330
|
+
|
|
331
|
+
for (const arg of rawArgs) {
|
|
332
|
+
if (arg === '--dry-run') {
|
|
333
|
+
dryRun = true;
|
|
334
|
+
} else if (arg === '--yes' || arg === '-y') {
|
|
335
|
+
yes = true;
|
|
336
|
+
} else if (!arg.startsWith('-')) {
|
|
337
|
+
packs.push(arg);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { packs, dryRun, yes };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export const addCommand = defineCommand({
|
|
345
|
+
meta: {
|
|
346
|
+
name: 'add',
|
|
347
|
+
description: 'Install one or more spark packs',
|
|
348
|
+
},
|
|
349
|
+
args: {
|
|
350
|
+
dryRun: {
|
|
351
|
+
type: 'boolean',
|
|
352
|
+
description: 'Print the install plan without writing files',
|
|
353
|
+
},
|
|
354
|
+
yes: {
|
|
355
|
+
type: 'boolean',
|
|
356
|
+
alias: 'y',
|
|
357
|
+
description: 'Skip confirmation prompt',
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
async run() {
|
|
361
|
+
const parsed = parseAddArgs(process.argv.slice(3));
|
|
362
|
+
await runAdd(parsed.packs, {
|
|
363
|
+
dryRun: parsed.dryRun,
|
|
364
|
+
yes: parsed.yes,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { readConfig, type AppSkillsConfig } from '../config.ts';
|
|
6
|
+
import { missingBoardTasks } from '../io/board.ts';
|
|
7
|
+
import { readRegistry, type Registry } from '../io/registry.ts';
|
|
8
|
+
import { readState } from '../io/state.ts';
|
|
9
|
+
import { installedRuntimeHelperSpecifier } from '../runtime-package.ts';
|
|
10
|
+
|
|
11
|
+
type CheckOutput = Pick<Console, 'log' | 'error'>;
|
|
12
|
+
|
|
13
|
+
export type DriftReport = {
|
|
14
|
+
missingFiles: string[];
|
|
15
|
+
missingEnv: string[];
|
|
16
|
+
missingTasks: string[];
|
|
17
|
+
missingHelpers: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CheckOptions = {
|
|
21
|
+
config?: AppSkillsConfig;
|
|
22
|
+
registry?: Registry;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const info = await stat(path);
|
|
28
|
+
return info.isFile();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeRegex(value: string): string {
|
|
38
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasEnvVar(content: string, key: string): boolean {
|
|
42
|
+
return new RegExp(`^\\s*(?:export\\s+)?${escapeRegex(key)}\\s*=`, 'm').test(content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readEnvLocal(projectRoot: string): Promise<string> {
|
|
46
|
+
try {
|
|
47
|
+
return await readFile(join(projectRoot, '.env.local'), 'utf8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runCheck(
|
|
57
|
+
projectRoot = process.cwd(),
|
|
58
|
+
output: CheckOutput = console,
|
|
59
|
+
options: CheckOptions = {},
|
|
60
|
+
): Promise<DriftReport> {
|
|
61
|
+
const [, registry, state] = await Promise.all([
|
|
62
|
+
options.config ? Promise.resolve(options.config) : readConfig(projectRoot),
|
|
63
|
+
options.registry ? Promise.resolve(options.registry) : readRegistry(projectRoot),
|
|
64
|
+
readState(projectRoot),
|
|
65
|
+
]);
|
|
66
|
+
const recordedFiles = [
|
|
67
|
+
...new Set(state.installed_packs.flatMap((pack) => pack.files)),
|
|
68
|
+
].sort();
|
|
69
|
+
const recordedEnv = [...new Set(state.installed_packs.flatMap((pack) => pack.env))].sort();
|
|
70
|
+
const recordedTasks = [...new Set(state.installed_packs.flatMap((pack) => pack.tasks))].sort();
|
|
71
|
+
|
|
72
|
+
const missingFiles: string[] = [];
|
|
73
|
+
for (const file of recordedFiles) {
|
|
74
|
+
if (!(await fileExists(join(projectRoot, file)))) {
|
|
75
|
+
missingFiles.push(file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const envLocal = await readEnvLocal(projectRoot);
|
|
80
|
+
const missingEnv = recordedEnv.filter((key) => !hasEnvVar(envLocal, key));
|
|
81
|
+
const missingTasks = await missingBoardTasks(projectRoot, recordedTasks);
|
|
82
|
+
const missingHelpers: string[] = [];
|
|
83
|
+
|
|
84
|
+
for (const pack of state.installed_packs) {
|
|
85
|
+
const runtimePackage = registry.packs.get(pack.name)?.manifest.runtime_package;
|
|
86
|
+
if (!runtimePackage) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!(await installedRuntimeHelperSpecifier(projectRoot, runtimePackage))) {
|
|
91
|
+
missingHelpers.push(
|
|
92
|
+
`${pack.name}: helper package ${runtimePackage.package} missing from package.json`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
missingFiles.length === 0 &&
|
|
99
|
+
missingEnv.length === 0 &&
|
|
100
|
+
missingTasks.length === 0 &&
|
|
101
|
+
missingHelpers.length === 0
|
|
102
|
+
) {
|
|
103
|
+
output.log(pc.green('OK: spark state matches the project filesystem.'));
|
|
104
|
+
return {
|
|
105
|
+
missingFiles,
|
|
106
|
+
missingEnv,
|
|
107
|
+
missingTasks,
|
|
108
|
+
missingHelpers,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
output.error(pc.red('Drift detected. spark check does not repair files.'));
|
|
113
|
+
if (missingFiles.length > 0) {
|
|
114
|
+
output.error('drift: missing files');
|
|
115
|
+
for (const file of missingFiles) {
|
|
116
|
+
output.error(` ${file}`);
|
|
117
|
+
}
|
|
118
|
+
output.error('suggestion: git restore the missing file or re-run the affected pack install');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (missingEnv.length > 0) {
|
|
122
|
+
output.error('missing env');
|
|
123
|
+
for (const key of missingEnv) {
|
|
124
|
+
output.error(` ${key}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (missingTasks.length > 0) {
|
|
129
|
+
output.error('missing tasks');
|
|
130
|
+
for (const taskId of missingTasks) {
|
|
131
|
+
output.error(` ${taskId}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (missingHelpers.length > 0) {
|
|
136
|
+
output.error('drift: helper packages');
|
|
137
|
+
for (const helper of missingHelpers) {
|
|
138
|
+
output.error(` ${helper}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
missingFiles,
|
|
144
|
+
missingEnv,
|
|
145
|
+
missingTasks,
|
|
146
|
+
missingHelpers,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const checkCommand = defineCommand({
|
|
151
|
+
meta: {
|
|
152
|
+
name: 'check',
|
|
153
|
+
description: 'Report drift between state.json and the project filesystem',
|
|
154
|
+
},
|
|
155
|
+
async run() {
|
|
156
|
+
const report = await runCheck();
|
|
157
|
+
if (
|
|
158
|
+
report.missingFiles.length > 0 ||
|
|
159
|
+
report.missingEnv.length > 0 ||
|
|
160
|
+
report.missingTasks.length > 0 ||
|
|
161
|
+
report.missingHelpers.length > 0
|
|
162
|
+
) {
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|