package gsp import ( "fmt" "os" "path/filepath" "strings" ) type InitOptions struct { Name string Entry string Force bool GSPVersion string ToolkitVersion string DefaultOutputFolder string } type InitResult struct { Root string `json:"root"` Created []string `json:"created,omitempty"` Updated []string `json:"updated,omitempty"` } func InitProject(root string, options InitOptions) (InitResult, error) { absRoot, err := filepath.Abs(root) if err != nil { return InitResult{}, err } if err := os.MkdirAll(absRoot, 0755); err != nil { return InitResult{}, err } name := strings.TrimSpace(options.Name) if name == "" { name = filepath.Base(absRoot) } entry := strings.TrimSpace(options.Entry) if entry == "" { entry = "project.entry" } gspVersion := strings.TrimSpace(options.GSPVersion) if gspVersion == "" { gspVersion = DefaultGSPVersion } toolkitVersion := strings.TrimSpace(options.ToolkitVersion) if toolkitVersion == "" { toolkitVersion = ToolkitVersion } output := strings.TrimSpace(options.DefaultOutputFolder) if output == "" { output = ".gsp" } result := InitResult{Root: filepath.ToSlash(absRoot)} designDir := filepath.Join(absRoot, "design") if err := os.MkdirAll(designDir, 0755); err != nil { return InitResult{}, err } result.Created = append(result.Created, relPath(absRoot, designDir)+"/") manifest := fmt.Sprintf(`gspVersion: %s toolkitVersion: %s project: %s entry: - %s scan: - design customFieldPolicy: strict fieldRegistry: gsp.fields stageRules: design: minResolution: L0 integrate: minResolution: L2 implement: minResolution: L3 bind: minResolution: L4 release: minResolution: L5 types: - concept - style - feedback - interaction - mechanic - page output: %s `, gspVersion, toolkitVersion, yamlScalar(name), yamlScalar(entry), yamlScalar(output)) entryFile := filepath.Join(designDir, safeFileName(entry)+".gsp") entryContent := fmt.Sprintf(`id: %s title: Project Entry type: concept resolution: L0 context: Project entry GSP. `, yamlScalar(entry)) fieldsContent := `gspFieldsVersion: 0.1 fields: {} ` if err := writeInitFile(absRoot, filepath.Join(absRoot, "gsp.manifest"), []byte(manifest), options.Force, &result); err != nil { return InitResult{}, err } if err := writeInitFile(absRoot, filepath.Join(absRoot, "gsp.fields"), []byte(fieldsContent), options.Force, &result); err != nil { return InitResult{}, err } if err := writeInitFile(absRoot, entryFile, []byte(entryContent), options.Force, &result); err != nil { return InitResult{}, err } return result, nil } func writeInitFile(root, path string, data []byte, force bool, result *InitResult) error { existed := false if _, err := os.Stat(path); err == nil { existed = true if !force { return fmt.Errorf("%s already exists; use --force to overwrite generated init files", relPath(root, path)) } } else if err != nil && !os.IsNotExist(err) { return err } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } if err := os.WriteFile(path, data, 0644); err != nil { return err } rel := relPath(root, path) if existed { result.Updated = append(result.Updated, rel) return nil } result.Created = append(result.Created, rel) return nil } func safeFileName(value string) string { var builder strings.Builder for _, r := range value { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= 'A' && r <= 'Z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == '.', r == '-', r == '_': builder.WriteRune(r) default: builder.WriteRune('_') } } name := strings.Trim(builder.String(), "._-") if name == "" { return "project.entry" } return name } func yamlScalar(value string) string { value = strings.TrimSpace(value) if value == "" { return `""` } if isPlainYAMLScalar(value) { return value } escaped := strings.ReplaceAll(value, `\`, `\\`) escaped = strings.ReplaceAll(escaped, `"`, `\"`) return `"` + escaped + `"` } func isPlainYAMLScalar(value string) bool { for _, r := range value { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r >= '0' && r <= '9': case r == '.', r == '-', r == '_': default: return false } } return true }