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 screenshots: [String]?
|
||||
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"`).
|
||||
/// 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 cron: Int?
|
||||
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 {
|
||||
@@ -130,10 +141,39 @@ struct TemplateInstallPlan: Sendable {
|
||||
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||
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
|
||||
/// 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 {
|
||||
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 cronJobNames: [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 {
|
||||
case templateId = "template_id"
|
||||
@@ -172,6 +223,8 @@ struct TemplateLock: Codable, Sendable {
|
||||
case skillsFiles = "skills_files"
|
||||
case cronJobNames = "cron_job_names"
|
||||
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"))
|
||||
}
|
||||
|
||||
// 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
|
||||
let manifest = ProjectTemplateManifest(
|
||||
schemaVersion: 1,
|
||||
schemaVersion: schemaVersion,
|
||||
id: inputs.templateId,
|
||||
name: inputs.templateName,
|
||||
version: inputs.templateVersion,
|
||||
@@ -204,8 +220,10 @@ struct ProjectTemplateExporter: Sendable {
|
||||
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||
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()
|
||||
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
|
||||
/// installer will feed back to `hermes cron create`. Only preserves
|
||||
/// fields the CLI accepts.
|
||||
|
||||
@@ -87,14 +87,46 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
let transport = context.makeTransport()
|
||||
try transport.createDirectory(plan.projectDir)
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||
@@ -189,6 +221,21 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
plan: TemplateInstallPlan,
|
||||
cronJobNames: [String]
|
||||
) 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(
|
||||
templateId: plan.manifest.id,
|
||||
templateVersion: plan.manifest.version,
|
||||
@@ -198,7 +245,9 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||
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()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
@@ -52,10 +52,27 @@ struct ProjectTemplateService: Sendable {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||
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(
|
||||
manifest: manifest,
|
||||
unpackedDir: inspection.unpackedDir,
|
||||
@@ -189,7 +237,10 @@ struct ProjectTemplateService: Sendable {
|
||||
cronJobs: cronJobs,
|
||||
memoryAppendix: memoryAppendix,
|
||||
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)"
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -183,6 +183,24 @@ struct ProjectTemplateUninstaller: Sendable {
|
||||
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
|
||||
// than name: user may have renamed the project in the UI).
|
||||
let dashboardService = ProjectDashboardService(context: context)
|
||||
|
||||
@@ -106,10 +106,15 @@ import Foundation
|
||||
id: String = "test/example",
|
||||
cron: Int? = nil,
|
||||
skills: [String]? = nil,
|
||||
instructions: [String]? = nil
|
||||
instructions: [String]? = nil,
|
||||
configFieldCount: Int? = nil,
|
||||
configSchema: TemplateConfigSchema? = nil
|
||||
) -> ProjectTemplateManifest {
|
||||
ProjectTemplateManifest(
|
||||
schemaVersion: 1,
|
||||
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||||
// that exercise the schema path mirror real manifest behaviour.
|
||||
let version = (configSchema != nil) ? 2 : 1
|
||||
return ProjectTemplateManifest(
|
||||
schemaVersion: version,
|
||||
id: id,
|
||||
name: "Example",
|
||||
version: "1.0.0",
|
||||
@@ -127,8 +132,10 @@ import Foundation
|
||||
instructions: instructions,
|
||||
skills: skills,
|
||||
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>/`
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user