From 64b7d3beafe6484fdeccce2fa53660805b8f4fbd Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 01:27:26 +0200 Subject: [PATCH] feat(config): manifest schemaVersion 2 + installer/uninstaller/exporter wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scarf/scarf/Core/Models/ProjectTemplate.swift | 57 +++- .../Services/ProjectTemplateExporter.swift | 41 ++- .../Services/ProjectTemplateInstaller.swift | 55 +++- .../Services/ProjectTemplateService.swift | 67 +++- .../Services/ProjectTemplateUninstaller.swift | 18 ++ scarf/scarfTests/ProjectTemplateTests.swift | 294 +++++++++++++++++- 6 files changed, 517 insertions(+), 15 deletions(-) diff --git a/scarf/scarf/Core/Models/ProjectTemplate.swift b/scarf/scarf/Core/Models/ProjectTemplate.swift index 172d89b..3f8702b 100644 --- a/scarf/scarf/Core/Models/ProjectTemplate.swift +++ b/scarf/scarf/Core/Models/ProjectTemplate.swift @@ -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 `/.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 `/.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" } } diff --git a/scarf/scarf/Core/Services/ProjectTemplateExporter.swift b/scarf/scarf/Core/Services/ProjectTemplateExporter.swift index 7db7573..ca1d3f2 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateExporter.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateExporter.swift @@ -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 `/.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. diff --git a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift index 2b6f0e8..e0aa1b0 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateInstaller.swift @@ -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 `/.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] diff --git a/scarf/scarf/Core/Services/ProjectTemplateService.swift b/scarf/scarf/Core/Services/ProjectTemplateService.swift index 7487360..9462ecc 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateService.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateService.swift @@ -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 diff --git a/scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift b/scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift index eb40c2c..cfa5d14 100644 --- a/scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift +++ b/scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift @@ -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) diff --git a/scarf/scarfTests/ProjectTemplateTests.swift b/scarf/scarfTests/ProjectTemplateTests.swift index a155863..c8a0e65 100644 --- a/scarf/scarfTests/ProjectTemplateTests.swift +++ b/scarf/scarfTests/ProjectTemplateTests.swift @@ -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///` /// 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.