mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(config): manifest schemaVersion 2 + installer/uninstaller/exporter wiring
Extends the template format to schemaVersion 2 (schema-less bundles at v1 keep working unchanged) and threads TemplateConfigSchema through inspect → buildPlan → install → uninstall → export end-to-end. Model additions (ProjectTemplate.swift): - ProjectTemplateManifest gains optional `config: TemplateConfigSchema?`. - TemplateContents gains optional `config: Int?` claim (field count) cross-checked against the schema by `verifyClaims` so a manifest can't hide its configuration from the preview sheet. - TemplateInstallPlan gains `configSchema`, `configValues` (populated by the VM just before install()), and `manifestCachePath`. New fields also feed totalWriteCount so the preview footer is honest. - TemplateLock gains optional `configKeychainItems: [String]?` and `configFields: [String]?`. Optional so pre-2.3 lock files still uninstall cleanly — Codable's default decoding skips missing fields. Service changes: - ProjectTemplateService.inspect now accepts schemaVersion 1 or 2. When the manifest declares a config block, the service validates it immediately via ProjectConfigService.validateSchema and fails the install with a manifestParseFailed before the preview sheet ever renders. verifyClaims cross-checks contents.config count against the actual schema length. - ProjectTemplateService.buildPlan populates configSchema and queues two new entries in projectFiles: .scarf/config.json (synthesized by the installer from configValues at write time, using an empty sourceRelativePath sentinel) and .scarf/manifest.json (copy of the bundle's template.json so the post-install Configuration editor can render offline). - ProjectTemplateInstaller.createProjectFiles now special-cases the empty-source sentinel: for .scarf/config.json, it encodes plan.configValues into a ProjectConfigFile on the fly. Secrets in that file are keychain:// refs — the raw bytes were routed into the Keychain by the VM before install() was called. - ProjectTemplateInstaller.writeLockFile records every keychainRef URI from configValues in lock.configKeychainItems and the schema field keys in lock.configFields. - ProjectTemplateUninstaller.uninstall adds a new step 4a: iterate lock.configKeychainItems, parse each URI into a TemplateKeychainRef, SecItemDelete each one. Absent items are no-ops (the Keychain wrapper already handles errSecItemNotFound silently). - ProjectTemplateExporter now reads the source project's .scarf/manifest.json (if present) and forwards the SCHEMA through to the exported bundle while zeroing values. schemaVersion bumps to 2 only when a schema is carried; schema-less exports stay at 1 for byte-compatibility with v2.2 catalog validators. Tests (ProjectTemplateTests.swift): 5 new tests in 1 new suite. - inspectAcceptsSchemaV2Bundle: v2 manifest unpacks cleanly. - buildPlanSurfacesSchemaAndQueuesConfigFiles: plan carries the schema; projectFiles contains both config.json + manifest.json. - verifyClaimsRejectsConfigCountMismatch: a manifest lying about contents.config vs. schema.fields.count is refused at inspect. - installWritesConfigJsonAndManifestCache: install round-trip writes config.json (with non-secret values inline + secret as keychainRef), manifest.json cache, and lock with configKeychainItems + configFields. Real Keychain is exercised; the test cleans up the single item it creates. - uninstallDeletesKeychainItemsViaLock: install + then uninstall, verify the Keychain entry is gone via SecItemCopyMatching. sampleManifest test helper gains `configFieldCount` and `configSchema` params so tests that want schemaful bundles don't need to rebuild the whole manifest record. schemaVersion auto-bumps to 2 when a schema is present so the fixture mirrors real bundle shape. 50/50 tests in 13 suites pass; pre-existing 45 from v2.2 unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,12 @@ struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
|||||||
let icon: String?
|
let icon: String?
|
||||||
let screenshots: [String]?
|
let screenshots: [String]?
|
||||||
let contents: TemplateContents
|
let contents: TemplateContents
|
||||||
|
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||||
|
/// When present, the installer presents a form during install and
|
||||||
|
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||||
|
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||||
|
/// optional-field decoding keeps them working unchanged.
|
||||||
|
let config: TemplateConfigSchema?
|
||||||
|
|
||||||
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||||
/// Used for the install directory name, skills namespace, and cron-job tag.
|
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||||
@@ -51,6 +57,11 @@ struct TemplateContents: Codable, Sendable, Equatable {
|
|||||||
let skills: [String]?
|
let skills: [String]?
|
||||||
let cron: Int?
|
let cron: Int?
|
||||||
let memory: TemplateMemoryClaim?
|
let memory: TemplateMemoryClaim?
|
||||||
|
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||||
|
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||||
|
/// validator so a bundle can't hide a schema from the preview.
|
||||||
|
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||||
|
let config: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||||
@@ -130,10 +141,39 @@ struct TemplateInstallPlan: Sendable {
|
|||||||
/// `ProjectEntry.name` that will be appended to the projects registry.
|
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||||
let projectRegistryName: String
|
let projectRegistryName: String
|
||||||
|
|
||||||
|
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||||
|
/// `nil` means the template is schema-less — the installer skips the
|
||||||
|
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||||
|
let configSchema: TemplateConfigSchema?
|
||||||
|
|
||||||
|
/// Values the user entered in the configure sheet. Populated by the
|
||||||
|
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||||
|
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||||
|
/// were routed straight from the form field into the Keychain and
|
||||||
|
/// never held in memory past that point.
|
||||||
|
var configValues: [String: TemplateConfigValue]
|
||||||
|
|
||||||
|
/// Path at which the installer will stash a copy of `template.json`
|
||||||
|
/// so the post-install Configuration editor can render the form
|
||||||
|
/// offline. `nil` when `configSchema` is nil.
|
||||||
|
let manifestCachePath: String?
|
||||||
|
|
||||||
/// Convenience: total number of writes (files + cron jobs + optional
|
/// Convenience: total number of writes (files + cron jobs + optional
|
||||||
/// memory append + registry append). Displayed in the preview sheet.
|
/// memory append + registry append + optional config.json + one
|
||||||
|
/// entry per secret written to the Keychain). Displayed in the
|
||||||
|
/// preview sheet.
|
||||||
nonisolated var totalWriteCount: Int {
|
nonisolated var totalWriteCount: Int {
|
||||||
projectFiles.count + skillsFiles.count + cronJobs.count + (memoryAppendix == nil ? 0 : 1) + 1
|
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||||
|
let secretCount = configValues.values.filter {
|
||||||
|
if case .keychainRef = $0 { return true } else { return false }
|
||||||
|
}.count
|
||||||
|
return projectFiles.count
|
||||||
|
+ skillsFiles.count
|
||||||
|
+ cronJobs.count
|
||||||
|
+ (memoryAppendix == nil ? 0 : 1)
|
||||||
|
+ 1 // registry entry
|
||||||
|
+ configFileCount
|
||||||
|
+ secretCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +201,17 @@ struct TemplateLock: Codable, Sendable {
|
|||||||
let skillsFiles: [String]
|
let skillsFiles: [String]
|
||||||
let cronJobNames: [String]
|
let cronJobNames: [String]
|
||||||
let memoryBlockId: String?
|
let memoryBlockId: String?
|
||||||
|
/// Every `keychain://service/account` URI the installer stored in
|
||||||
|
/// the Keychain for this project's secret fields. Empty/nil for
|
||||||
|
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||||
|
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||||
|
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||||
|
/// uninstallable.
|
||||||
|
let configKeychainItems: [String]?
|
||||||
|
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||||
|
/// Informational — the actual removal of config.json rides on
|
||||||
|
/// `projectFiles`. Optional for back-compat.
|
||||||
|
let configFields: [String]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case templateId = "template_id"
|
case templateId = "template_id"
|
||||||
@@ -172,6 +223,8 @@ struct TemplateLock: Codable, Sendable {
|
|||||||
case skillsFiles = "skills_files"
|
case skillsFiles = "skills_files"
|
||||||
case cronJobNames = "cron_job_names"
|
case cronJobNames = "cron_job_names"
|
||||||
case memoryBlockId = "memory_block_id"
|
case memoryBlockId = "memory_block_id"
|
||||||
|
case configKeychainItems = "config_keychain_items"
|
||||||
|
case configFields = "config_fields"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,9 +182,25 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the source project was itself installed from a schemaful
|
||||||
|
// template, its `.scarf/manifest.json` carries the schema we
|
||||||
|
// want to forward to the exported bundle. We carry only the
|
||||||
|
// SCHEMA — never user values. Exporting must be safe on a
|
||||||
|
// project with live config: the schema is author-supplied
|
||||||
|
// metadata; the values in `config.json` are the current user's
|
||||||
|
// secrets or personal settings.
|
||||||
|
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||||
|
from: plan.projectDir
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bump schemaVersion to 2 when a schema is carried through;
|
||||||
|
// remain on 1 otherwise so schema-less exports stay
|
||||||
|
// byte-compatible with existing v2.2 catalog validators.
|
||||||
|
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||||
|
|
||||||
// Manifest — claims exactly what we just wrote
|
// Manifest — claims exactly what we just wrote
|
||||||
let manifest = ProjectTemplateManifest(
|
let manifest = ProjectTemplateManifest(
|
||||||
schemaVersion: 1,
|
schemaVersion: schemaVersion,
|
||||||
id: inputs.templateId,
|
id: inputs.templateId,
|
||||||
name: inputs.templateName,
|
name: inputs.templateName,
|
||||||
version: inputs.templateVersion,
|
version: inputs.templateVersion,
|
||||||
@@ -204,8 +220,10 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||||
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||||
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil
|
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||||
)
|
config: forwardedSchema?.fields.count
|
||||||
|
),
|
||||||
|
config: forwardedSchema
|
||||||
)
|
)
|
||||||
let manifestEncoder = JSONEncoder()
|
let manifestEncoder = JSONEncoder()
|
||||||
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
@@ -239,6 +257,23 @@ struct ProjectTemplateExporter: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||||
|
/// present) and pull out just the config schema. Values in
|
||||||
|
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||||
|
/// bundle carries the schema's shape, never the current user's
|
||||||
|
/// configured values.
|
||||||
|
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||||
|
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||||
|
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||||
|
// this helper stays resilient if the manifest shape evolves
|
||||||
|
// incompatibly in a future release.
|
||||||
|
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||||
|
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||||
|
return onlyConfig.config
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a live cron job (with runtime state) into the spec the
|
/// Convert a live cron job (with runtime state) into the spec the
|
||||||
/// installer will feed back to `hermes cron create`. Only preserves
|
/// installer will feed back to `hermes cron create`. Only preserves
|
||||||
/// fields the CLI accepts.
|
/// fields the CLI accepts.
|
||||||
|
|||||||
@@ -87,14 +87,46 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
try transport.createDirectory(plan.projectDir)
|
try transport.createDirectory(plan.projectDir)
|
||||||
for copy in plan.projectFiles {
|
for copy in plan.projectFiles {
|
||||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
|
||||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||||
try transport.createDirectory(parent)
|
try transport.createDirectory(parent)
|
||||||
|
|
||||||
|
// Empty `sourceRelativePath` is the "synthesized content"
|
||||||
|
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||||
|
// The installer materialises config.json from
|
||||||
|
// `plan.configValues` here rather than copying a bundle
|
||||||
|
// file that doesn't exist.
|
||||||
|
if copy.sourceRelativePath.isEmpty {
|
||||||
|
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||||
|
let data = try encodeConfigFile(plan: plan)
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(
|
||||||
|
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||||
try transport.writeFile(copy.destinationPath, data: data)
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||||
|
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||||
|
/// routed into the Keychain by the VM before `install()` was called.
|
||||||
|
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: plan.manifest.id,
|
||||||
|
values: plan.configValues,
|
||||||
|
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return try encoder.encode(file)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Skills
|
// MARK: - Skills
|
||||||
|
|
||||||
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||||
@@ -189,6 +221,21 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
plan: TemplateInstallPlan,
|
plan: TemplateInstallPlan,
|
||||||
cronJobNames: [String]
|
cronJobNames: [String]
|
||||||
) throws {
|
) throws {
|
||||||
|
// Every value that ended up as a keychainRef in config.json gets
|
||||||
|
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||||
|
// entry. Field keys are recorded separately for informational
|
||||||
|
// display in the uninstall preview sheet.
|
||||||
|
let keychainItems: [String]? = {
|
||||||
|
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||||
|
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||||
|
}
|
||||||
|
return refs.isEmpty ? nil : refs.sorted()
|
||||||
|
}()
|
||||||
|
let configFields: [String]? = {
|
||||||
|
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||||
|
return schema.fields.map(\.key)
|
||||||
|
}()
|
||||||
|
|
||||||
let lock = TemplateLock(
|
let lock = TemplateLock(
|
||||||
templateId: plan.manifest.id,
|
templateId: plan.manifest.id,
|
||||||
templateVersion: plan.manifest.version,
|
templateVersion: plan.manifest.version,
|
||||||
@@ -198,7 +245,9 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
skillsNamespaceDir: plan.skillsNamespaceDir,
|
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||||
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||||
cronJobNames: cronJobNames,
|
cronJobNames: cronJobNames,
|
||||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id
|
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||||
|
configKeychainItems: keychainItems,
|
||||||
|
configFields: configFields
|
||||||
)
|
)
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
|||||||
@@ -52,10 +52,27 @@ struct ProjectTemplateService: Sendable {
|
|||||||
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard manifest.schemaVersion == 1 else {
|
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||||
|
// optional `config` block. Both are valid. Newer versions get
|
||||||
|
// refused so the installer never silently misinterprets a
|
||||||
|
// future-shape bundle.
|
||||||
|
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||||
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the optional config schema at inspect time — a
|
||||||
|
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||||
|
// gets rejected before the user ever sees the preview sheet.
|
||||||
|
if let schema = manifest.config {
|
||||||
|
do {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(
|
||||||
|
"invalid config schema: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let files = try Self.walk(unpackedDir)
|
let files = try Self.walk(unpackedDir)
|
||||||
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||||
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||||
@@ -179,6 +196,37 @@ struct ProjectTemplateService: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configuration schema + manifest cache. The installer writes
|
||||||
|
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||||
|
// (schema cache used by the post-install editor) when the
|
||||||
|
// template declares a non-empty schema. Both paths go into
|
||||||
|
// projectFiles so the uninstaller picks them up via the lock.
|
||||||
|
var configSchema: TemplateConfigSchema? = nil
|
||||||
|
var manifestCachePath: String? = nil
|
||||||
|
if let schema = manifest.config, !schema.isEmpty {
|
||||||
|
configSchema = schema
|
||||||
|
let configPath = projectDir + "/.scarf/config.json"
|
||||||
|
projectFiles.append(
|
||||||
|
// Source is synthesized by the installer from configValues;
|
||||||
|
// no file in the unpacked bundle maps to this entry. We use
|
||||||
|
// an empty `sourceRelativePath` as the "no physical source"
|
||||||
|
// sentinel — the installer special-cases it below (see
|
||||||
|
// ProjectTemplateInstaller.createProjectFiles).
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "",
|
||||||
|
destinationPath: configPath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||||
|
manifestCachePath = cachePath
|
||||||
|
projectFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "template.json",
|
||||||
|
destinationPath: cachePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return TemplateInstallPlan(
|
return TemplateInstallPlan(
|
||||||
manifest: manifest,
|
manifest: manifest,
|
||||||
unpackedDir: inspection.unpackedDir,
|
unpackedDir: inspection.unpackedDir,
|
||||||
@@ -189,7 +237,10 @@ struct ProjectTemplateService: Sendable {
|
|||||||
cronJobs: cronJobs,
|
cronJobs: cronJobs,
|
||||||
memoryAppendix: memoryAppendix,
|
memoryAppendix: memoryAppendix,
|
||||||
memoryPath: context.paths.memoryMD,
|
memoryPath: context.paths.memoryMD,
|
||||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context)
|
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||||
|
configSchema: configSchema,
|
||||||
|
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||||
|
manifestCachePath: manifestCachePath
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +469,18 @@ struct ProjectTemplateService: Sendable {
|
|||||||
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config claim must match the schema's actual field count so
|
||||||
|
// the preview sheet is honest about the size of the configure
|
||||||
|
// step. `nil` in contents means "no schema" just like `0`;
|
||||||
|
// we normalise both to 0 before comparing.
|
||||||
|
let claimedConfig = manifest.contents.config ?? 0
|
||||||
|
let actualConfig = manifest.config?.fields.count ?? 0
|
||||||
|
if claimedConfig != actualConfig {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a project-registry name that doesn't collide. Deterministic
|
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||||
|
|||||||
@@ -183,6 +183,24 @@ struct ProjectTemplateUninstaller: Sendable {
|
|||||||
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4a. Config Keychain items — remove every secret the template's
|
||||||
|
// install step stashed in the login Keychain. Items that were
|
||||||
|
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||||
|
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||||
|
// a stale lock doesn't abort the rest of the uninstall.
|
||||||
|
let keychain = ProjectConfigKeychain()
|
||||||
|
for uri in plan.lock.configKeychainItems ?? [] {
|
||||||
|
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Projects registry — remove the entry by path (more stable
|
// 5. Projects registry — remove the entry by path (more stable
|
||||||
// than name: user may have renamed the project in the UI).
|
// than name: user may have renamed the project in the UI).
|
||||||
let dashboardService = ProjectDashboardService(context: context)
|
let dashboardService = ProjectDashboardService(context: context)
|
||||||
|
|||||||
@@ -106,10 +106,15 @@ import Foundation
|
|||||||
id: String = "test/example",
|
id: String = "test/example",
|
||||||
cron: Int? = nil,
|
cron: Int? = nil,
|
||||||
skills: [String]? = nil,
|
skills: [String]? = nil,
|
||||||
instructions: [String]? = nil
|
instructions: [String]? = nil,
|
||||||
|
configFieldCount: Int? = nil,
|
||||||
|
configSchema: TemplateConfigSchema? = nil
|
||||||
) -> ProjectTemplateManifest {
|
) -> ProjectTemplateManifest {
|
||||||
ProjectTemplateManifest(
|
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||||||
schemaVersion: 1,
|
// that exercise the schema path mirror real manifest behaviour.
|
||||||
|
let version = (configSchema != nil) ? 2 : 1
|
||||||
|
return ProjectTemplateManifest(
|
||||||
|
schemaVersion: version,
|
||||||
id: id,
|
id: id,
|
||||||
name: "Example",
|
name: "Example",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -127,8 +132,10 @@ import Foundation
|
|||||||
instructions: instructions,
|
instructions: instructions,
|
||||||
skills: skills,
|
skills: skills,
|
||||||
cron: cron,
|
cron: cron,
|
||||||
memory: nil
|
memory: nil,
|
||||||
)
|
config: configFieldCount ?? configSchema?.fields.count
|
||||||
|
),
|
||||||
|
config: configSchema
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +491,283 @@ import Foundation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End-to-end tests for manifest schemaVersion 2 (template configuration).
|
||||||
|
/// Exercises the full cycle: inspect → buildPlan → install → uninstall
|
||||||
|
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||||||
|
/// service suffix so no leftover login-Keychain items remain after the
|
||||||
|
/// test — every secret we write is deleted on teardown.
|
||||||
|
@Suite struct ProjectTemplateConfigInstallTests {
|
||||||
|
|
||||||
|
/// Minimal schemaful manifest with one non-secret field + one
|
||||||
|
/// secret field. Written into the synthesized `.scarftemplate`
|
||||||
|
/// bundle for the round-trip tests.
|
||||||
|
static func makeSchemafulManifest() -> ProjectTemplateManifest {
|
||||||
|
ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/configured",
|
||||||
|
configSchema: TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "site_url", type: .string, label: "Site URL",
|
||||||
|
description: "where to ping", required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil),
|
||||||
|
.init(key: "api_token", type: .secret, label: "API Token",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil),
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectAcceptsSchemaV2Bundle() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestData = try JSONEncoder().encode(manifest)
|
||||||
|
let manifestString = String(data: manifestData, encoding: .utf8)!
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestString,
|
||||||
|
"README.md": "# r",
|
||||||
|
"AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
|
#expect(inspection.manifest.schemaVersion == 2)
|
||||||
|
#expect(inspection.manifest.config?.fields.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func buildPlanSurfacesSchemaAndQueuesConfigFiles() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||||
|
|
||||||
|
// Schema carried through the plan.
|
||||||
|
#expect(plan.configSchema?.fields.count == 2)
|
||||||
|
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||||
|
// config.json + manifest.json entries in projectFiles.
|
||||||
|
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifyClaimsRejectsConfigCountMismatch() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
// Hand-build a manifest whose contents.config claim (2) doesn't
|
||||||
|
// match its schema.fields.count (1) — validator should reject.
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "only", type: .string, label: "Only",
|
||||||
|
description: nil, required: false, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let bogus = ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/mismatch",
|
||||||
|
configFieldCount: 2, // claim lies
|
||||||
|
configSchema: schema // reality is 1
|
||||||
|
)
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(bogus), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.inspect(zipPath: bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func installWritesConfigJsonAndManifestCache() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
// Isolated Keychain service suffix so the test doesn't touch
|
||||||
|
// the real login Keychain.
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let configService = ProjectConfigService(keychain: keychain)
|
||||||
|
|
||||||
|
// Store secret via the service (VM would do this before install).
|
||||||
|
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||||
|
let secretRef = try configService.storeSecret(
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
fieldKey: "api_token",
|
||||||
|
project: project,
|
||||||
|
secret: Data("sk-top-secret".utf8)
|
||||||
|
)
|
||||||
|
plan.configValues = [
|
||||||
|
"site_url": .string("https://example.com"),
|
||||||
|
"api_token": secretRef
|
||||||
|
]
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
_ = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
// config.json landed with non-secret values + keychain ref.
|
||||||
|
let configPath = plan.projectDir + "/.scarf/config.json"
|
||||||
|
#expect(FileManager.default.fileExists(atPath: configPath))
|
||||||
|
let configData = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
||||||
|
let configFile = try JSONDecoder().decode(ProjectConfigFile.self, from: configData)
|
||||||
|
#expect(configFile.values["site_url"] == .string("https://example.com"))
|
||||||
|
if case .keychainRef(let uri) = configFile.values["api_token"] {
|
||||||
|
#expect(uri.hasPrefix("keychain://"))
|
||||||
|
} else {
|
||||||
|
Issue.record("api_token should have been stored as keychainRef")
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest.json cache landed for the post-install editor.
|
||||||
|
let cachePath = plan.projectDir + "/.scarf/manifest.json"
|
||||||
|
#expect(FileManager.default.fileExists(atPath: cachePath))
|
||||||
|
let cachedManifest = try JSONDecoder().decode(
|
||||||
|
ProjectTemplateManifest.self,
|
||||||
|
from: Data(contentsOf: URL(fileURLWithPath: cachePath))
|
||||||
|
)
|
||||||
|
#expect(cachedManifest.config?.fields.count == 2)
|
||||||
|
|
||||||
|
// Lock file records the keychain item so uninstall can clean up.
|
||||||
|
let lockPath = plan.projectDir + "/.scarf/template.lock.json"
|
||||||
|
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath))
|
||||||
|
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||||
|
#expect(lock.configKeychainItems?.count == 1)
|
||||||
|
#expect(lock.configFields == ["site_url", "api_token"])
|
||||||
|
|
||||||
|
// Clean up the real Keychain entry we created outside the
|
||||||
|
// test-suffixed namespace (storeSecret uses real service name
|
||||||
|
// because the test's config-service wasn't isolated for this
|
||||||
|
// call's secret; we manually delete via our test keychain).
|
||||||
|
if let ref = TemplateKeychainRef.parse(
|
||||||
|
(configFile.values["api_token"].flatMap { v -> String? in
|
||||||
|
if case .keychainRef(let u) = v { return u } else { return nil }
|
||||||
|
}) ?? ""
|
||||||
|
) {
|
||||||
|
try? ProjectConfigKeychain().delete(ref: ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func uninstallDeletesKeychainItemsViaLock() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
// Real Keychain — we store, install, then uninstall and verify
|
||||||
|
// the item is gone. Uses the real service name (no test suffix)
|
||||||
|
// because the installer + uninstaller go through their own
|
||||||
|
// ProjectConfigKeychain instances without a suffix.
|
||||||
|
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||||
|
let configService = ProjectConfigService()
|
||||||
|
let secretRef = try configService.storeSecret(
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
fieldKey: "api_token",
|
||||||
|
project: project,
|
||||||
|
secret: Data("delete-me".utf8)
|
||||||
|
)
|
||||||
|
plan.configValues = [
|
||||||
|
"site_url": .string("https://example.com"),
|
||||||
|
"api_token": secretRef
|
||||||
|
]
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
// Verify the secret is there before uninstall.
|
||||||
|
guard case .keychainRef(let uri) = secretRef,
|
||||||
|
let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
Issue.record("expected secret to be a keychainRef")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect((try ProjectConfigKeychain().get(ref: ref)) == Data("delete-me".utf8))
|
||||||
|
|
||||||
|
// Uninstall → secret should be gone.
|
||||||
|
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||||
|
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||||
|
try uninstaller.uninstall(plan: uninstallPlan)
|
||||||
|
|
||||||
|
#expect((try ProjectConfigKeychain().get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||||
|
|
||||||
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
|
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
|
||||||
/// in the repo. A template whose manifest, `contents` claim, or file set is
|
/// in the repo. A template whose manifest, `contents` claim, or file set is
|
||||||
/// out of sync will fail here — so shipped templates can't silently rot.
|
/// out of sync will fail here — so shipped templates can't silently rot.
|
||||||
|
|||||||
Reference in New Issue
Block a user