@nakedev/go-scaffold 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -3,18 +3,45 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CONFIG_SCHEMA_VERSION = void 0;
6
7
  exports.configPath = configPath;
8
+ exports.parseModuleSurface = parseModuleSurface;
9
+ exports.parseApplicationStyle = parseApplicationStyle;
10
+ exports.parseModuleProfile = parseModuleProfile;
7
11
  exports.writeConfig = writeConfig;
8
12
  exports.readConfig = readConfig;
9
13
  exports.isProjectDir = isProjectDir;
10
14
  const path_1 = __importDefault(require("path"));
11
15
  const fs_extra_1 = __importDefault(require("fs-extra"));
16
+ const types_1 = require("../types");
12
17
  const CONFIG_FILE = "go-scaffold.config.json";
18
+ exports.CONFIG_SCHEMA_VERSION = 1;
13
19
  function configPath(projectDir) {
14
20
  return path_1.default.join(projectDir, CONFIG_FILE);
15
21
  }
22
+ function parseModuleSurface(value) {
23
+ if (value === undefined)
24
+ return undefined;
25
+ const normalized = value.trim().toLowerCase();
26
+ assertOneOf(normalized, "--module-surface", ["minimal", "crud"]);
27
+ return normalized;
28
+ }
29
+ function parseApplicationStyle(value) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ const normalized = value.trim().toLowerCase();
33
+ assertOneOf(normalized, "--application-style", ["service", "cqrs"]);
34
+ return normalized;
35
+ }
36
+ function parseModuleProfile(value, flag = "--profile") {
37
+ if (value === undefined)
38
+ return undefined;
39
+ const normalized = value.trim().toLowerCase();
40
+ assertOneOf(normalized, flag, ["lean", "crud", "cqrs"]);
41
+ return normalized;
42
+ }
16
43
  function writeConfig(projectDir, config) {
17
- fs_extra_1.default.writeJsonSync(configPath(projectDir), config, { spaces: 2 });
44
+ fs_extra_1.default.writeJsonSync(configPath(projectDir), normalizeProjectConfig(config, projectDir), { spaces: 2 });
18
45
  }
19
46
  // readConfig falls back to detecting from go.mod when the config file is
20
47
  // missing (e.g. a project scaffolded before this file existed).
@@ -35,13 +62,127 @@ function readConfig(projectDir) {
35
62
  if (!fs_extra_1.default.existsSync(file))
36
63
  return detectConfig(projectDir);
37
64
  const config = fs_extra_1.default.readJsonSync(file);
65
+ if (!isRecord(config)) {
66
+ throw new Error(`${CONFIG_FILE} must contain a JSON object`);
67
+ }
68
+ const detected = detectFeatures(projectDir);
69
+ const features = { ...(isRecord(config.features) ? config.features : {}) };
70
+ for (const key of Object.keys(detected)) {
71
+ if (features[key] === undefined)
72
+ features[key] = detected[key];
73
+ }
74
+ return normalizeProjectConfig({ ...config, features: features }, projectDir);
75
+ }
76
+ /**
77
+ * Validate and fill defaults for the project manifest. Older projects have
78
+ * no architecture/modules keys yet; normalising them here lets every command
79
+ * consume one stable shape while keeping the old config file compatible.
80
+ */
81
+ function normalizeProjectConfig(raw, projectDir) {
82
+ const projectName = requiredString(raw.projectName, "projectName");
83
+ const goModule = requiredString(raw.goModule, "goModule");
84
+ const apiPrefix = requiredString(raw.apiPrefix ?? "", "apiPrefix");
85
+ const schemaVersion = raw.schemaVersion ?? exports.CONFIG_SCHEMA_VERSION;
86
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
87
+ throw new Error(`${CONFIG_FILE} has invalid schemaVersion "${String(schemaVersion)}" — expected a positive integer`);
88
+ }
89
+ if (schemaVersion > exports.CONFIG_SCHEMA_VERSION) {
90
+ throw new Error(`${CONFIG_FILE} uses schemaVersion ${schemaVersion}, but this CLI supports up to ${exports.CONFIG_SCHEMA_VERSION} — upgrade go-scaffold first`);
91
+ }
38
92
  const detected = detectFeatures(projectDir);
39
- const features = { ...config.features };
93
+ const features = { ...(isRecord(raw.features) ? raw.features : {}) };
40
94
  for (const key of Object.keys(detected)) {
41
95
  if (features[key] === undefined)
42
96
  features[key] = detected[key];
43
97
  }
44
- return { ...config, features };
98
+ validateFeatures(features);
99
+ const architecture = normalizeArchitecture(raw.architecture);
100
+ const modules = normalizeModules(raw.modules);
101
+ return {
102
+ schemaVersion,
103
+ projectName,
104
+ goModule,
105
+ apiPrefix,
106
+ features: features,
107
+ architecture,
108
+ modules,
109
+ ...(raw.scaffoldVersion ? { scaffoldVersion: raw.scaffoldVersion } : {}),
110
+ };
111
+ }
112
+ function normalizeArchitecture(raw) {
113
+ const value = raw === undefined ? {} : raw;
114
+ if (!isRecord(value)) {
115
+ throw new Error(`${CONFIG_FILE}.architecture must be a JSON object`);
116
+ }
117
+ const architecture = {
118
+ ...types_1.DEFAULT_ARCHITECTURE_CONFIG,
119
+ ...value,
120
+ };
121
+ assertOneOf(architecture.style, "architecture.style", ["modular-monolith"]);
122
+ assertOneOf(architecture.defaultModuleSurface, "architecture.defaultModuleSurface", ["minimal", "crud"]);
123
+ assertOneOf(architecture.defaultApplicationStyle, "architecture.defaultApplicationStyle", ["service", "cqrs"]);
124
+ return {
125
+ style: architecture.style,
126
+ defaultModuleSurface: architecture.defaultModuleSurface,
127
+ defaultApplicationStyle: architecture.defaultApplicationStyle,
128
+ };
129
+ }
130
+ function normalizeModules(raw) {
131
+ if (raw === undefined)
132
+ return {};
133
+ if (!isRecord(raw))
134
+ throw new Error(`${CONFIG_FILE}.modules must be a JSON object`);
135
+ const modules = {};
136
+ for (const [name, entry] of Object.entries(raw)) {
137
+ if (!name || name.includes("/") || name.includes("\\")) {
138
+ throw new Error(`${CONFIG_FILE}.modules has invalid module key "${name}" — use the module's Go package name`);
139
+ }
140
+ if (!isRecord(entry))
141
+ throw new Error(`${CONFIG_FILE}.modules.${name} must be a JSON object`);
142
+ assertOneOf(entry.surface, `modules.${name}.surface`, ["minimal", "crud"]);
143
+ assertOneOf(entry.applicationStyle, `modules.${name}.applicationStyle`, ["service", "cqrs"]);
144
+ modules[name] = {
145
+ surface: entry.surface,
146
+ applicationStyle: entry.applicationStyle,
147
+ };
148
+ }
149
+ return modules;
150
+ }
151
+ function validateFeatures(features) {
152
+ if (typeof features.docker !== "boolean")
153
+ throw new Error(`${CONFIG_FILE}.features.docker must be true or false`);
154
+ if (typeof features.openapiDocs !== "boolean")
155
+ throw new Error(`${CONFIG_FILE}.features.openapiDocs must be true or false`);
156
+ if (features.worker !== undefined && typeof features.worker !== "boolean") {
157
+ throw new Error(`${CONFIG_FILE}.features.worker must be true or false`);
158
+ }
159
+ if (features.queue !== undefined)
160
+ assertOneOf(features.queue, "features.queue", ["river", "asynq"]);
161
+ if (features.auth !== undefined && typeof features.auth !== "boolean") {
162
+ throw new Error(`${CONFIG_FILE}.features.auth must be true or false`);
163
+ }
164
+ if (features.authStore !== undefined)
165
+ assertOneOf(features.authStore, "features.authStore", ["postgres", "redis"]);
166
+ if (features.rbac !== undefined && typeof features.rbac !== "boolean") {
167
+ throw new Error(`${CONFIG_FILE}.features.rbac must be true or false`);
168
+ }
169
+ if (features.observability !== undefined && typeof features.observability !== "boolean") {
170
+ throw new Error(`${CONFIG_FILE}.features.observability must be true or false`);
171
+ }
172
+ }
173
+ function requiredString(value, field) {
174
+ if (typeof value !== "string")
175
+ throw new Error(`${CONFIG_FILE}.${field} must be a string`);
176
+ return value;
177
+ }
178
+ function assertOneOf(value, field, allowed) {
179
+ if (typeof value !== "string" || !allowed.includes(value)) {
180
+ const label = field.startsWith("--") ? field : `${CONFIG_FILE}.${field}`;
181
+ throw new Error(`${label} must be one of: ${allowed.join(", ")} (got "${String(value)}")`);
182
+ }
183
+ }
184
+ function isRecord(value) {
185
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
186
  }
46
187
  // detectFeatures answers "what is actually installed here" from the tree
47
188
  // alone. Every `add` command leaves a directory or a file behind that nothing
@@ -107,10 +248,13 @@ function detectConfig(projectDir) {
107
248
  // already exists"), with no way out. Worse, the first `add` to succeed then
108
249
  // wrote a config that recorded the undetected features as absent.
109
250
  return {
251
+ schemaVersion: exports.CONFIG_SCHEMA_VERSION,
110
252
  projectName: path_1.default.basename(projectDir),
111
253
  goModule,
112
254
  apiPrefix,
113
255
  features: detectFeatures(projectDir),
256
+ architecture: { ...types_1.DEFAULT_ARCHITECTURE_CONFIG },
257
+ modules: {},
114
258
  };
115
259
  }
116
260
  // isProjectDir answers "would readConfig succeed here?" without throwing, so
@@ -18,26 +18,28 @@ const UNUSED_API_LINE = "_ = api // dropped once `generate module` registers the
18
18
  // the exact lines patchMainGo inserts for a module — one source of truth so
19
19
  // unpatchMainGo removes precisely what patch added.
20
20
  //
21
- // The service gets its own named variable rather than being constructed
22
- // inline inside NewHandler(...). docs/architect/patterns.md tells you to wire
23
- // one domain's service into another's constructor when it needs behaviour
24
- // from it, which means editing this block by hand — and an inline expression
25
- // has nowhere to hold the result. With a named variable the edit is "add an
26
- // argument to the end of line one", and unpatchMainGo can still find the line
27
- // afterwards because it matches on the `<pkg>Svc :=` prefix, not the whole
28
- // text.
21
+ // Generated features own their repository/service/handler composition. The
22
+ // API binary supplies infrastructure and security dependencies, then only
23
+ // registers the feature's handler.
29
24
  function mainGoLines(patch) {
30
25
  const modelAlias = `${patch.pkg}model`; // every domain's model subpackage is named "model"
31
- const svcVar = `${patch.pkg}Svc`;
32
- const handlerArgs = [svcVar];
26
+ const handlerArgs = ["db"];
27
+ const legacyHandlerArgs = [`${patch.pkg}Svc`];
33
28
  if (patch.auth)
34
29
  handlerArgs.push("cfg.JWTSecret");
30
+ // RBAC's Authz is owned by role/composition.go. wiring.go registers a
31
+ // permission-gated feature with that public capability; it no longer keeps a
32
+ // standalone root variable named `authz`.
35
33
  if (patch.permission)
36
- handlerArgs.push("authz");
34
+ handlerArgs.push("roleComposition.Authz");
35
+ if (patch.auth)
36
+ legacyHandlerArgs.push("cfg.JWTSecret");
37
+ if (patch.permission)
38
+ legacyHandlerArgs.push("roleComposition.Authz");
37
39
  return {
38
40
  importLine: `"${patch.goModule}/internal/app/${patch.modulePath}"`,
39
41
  modelImportLine: `${modelAlias} "${patch.goModule}/internal/app/${patch.modulePath}/model"`,
40
- // AutoMigrate (dev) creates tables but not the schema they live in — see
42
+ // Development schema bootstrap creates tables but not the schema they live in — see
41
43
  // the comment on go-scaffold:schemas in main.go.hbs. One Exec per schema,
42
44
  // guarded by its own sentinel so two modules sharing a schema name only
43
45
  // ever produce one line (not expected today, but cheap to keep safe).
@@ -48,15 +50,17 @@ function mainGoLines(patch) {
48
50
  ].join("\n"),
49
51
  schemaSentinel: `CREATE SCHEMA IF NOT EXISTS ${patch.schemaName}`,
50
52
  migrateLine: `&${modelAlias}.${patch.pascalName}{},`,
51
- serviceLine: `${svcVar} := ${patch.pkg}.NewService(${patch.pkg}.NewRepository(db))`,
52
- // matches the service line however the user has since extended it
53
- servicePrefix: `${svcVar} :=`,
53
+ // kept only so undo can clean projects generated before feature-local
54
+ // composition was introduced.
55
+ legacyServicePrefix: `${patch.pkg}Svc :=`,
56
+ legacyRouteLine: `${patch.pkg}.NewHandler(${legacyHandlerArgs.join(", ")}).Register(api)`,
54
57
  // `api` is the one route group declared by main.go.hbs, prefixed with
55
58
  // whatever apiPrefix the project chose at create time (e.g. /v1, /api,
56
59
  // or none) — every module registers on it, there is no per-module choice.
57
- // `authz` only exists in main.go once `add rbac` has run — patch.permission
58
- // is only ever set once that's already been verified by the caller.
59
- routeLine: `${patch.pkg}.NewHandler(${handlerArgs.join(", ")}).Register(api)`,
60
+ // `roleComposition.Authz` only exists in main.go once `add rbac` has run —
61
+ // patch.permission is only ever set once that has been verified by the
62
+ // caller.
63
+ routeLine: `${patch.pkg}.NewHandlerFromDB(${handlerArgs.join(", ")}).Register(api)`,
60
64
  };
61
65
  }
62
66
  // assertMainGoPatchable is the pre-flight for it: every marker patchMainGo
@@ -79,13 +83,13 @@ function assertMainGoPatchable(mainGoPath) {
79
83
  }
80
84
  }
81
85
  // patchMainGo wires a newly generated module into cmd/api/wiring.go: its
82
- // import, its model in the AutoMigrate call, and its route registration —
86
+ // import, its model in the development bootstrap, and its route registration —
83
87
  // via marker comments rather than a Go AST rewrite (ponytail: text insertion
84
88
  // at a fixed marker is enough here; reach for go/ast if main.go ever needs
85
89
  // edits markers can't express).
86
90
  function patchMainGo(mainGoPath, patch) {
87
91
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
88
- const { importLine, modelImportLine, schemaLines, schemaSentinel, migrateLine, serviceLine, servicePrefix, routeLine } = mainGoLines(patch);
92
+ const { importLine, modelImportLine, schemaLines, schemaSentinel, migrateLine, routeLine } = mainGoLines(patch);
89
93
  // each guarded by its own sentinel so re-running after only the module
90
94
  // folder was deleted (main.go still wired) is a no-op, not a dup that
91
95
  // panics gin at startup.
@@ -93,9 +97,6 @@ function patchMainGo(mainGoPath, patch) {
93
97
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
94
98
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaLines, schemaSentinel);
95
99
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine, migrateLine);
96
- // sentinel is the prefix, not the whole line: a re-run must not add a
97
- // second service line just because the first one gained a dependency.
98
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, serviceLine, servicePrefix);
99
100
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
100
101
  content = (0, marker_patch_1.removeLines)(content, [UNUSED_API_LINE]);
101
102
  fs_extra_1.default.writeFileSync(mainGoPath, content);
@@ -105,10 +106,11 @@ function patchMainGo(mainGoPath, patch) {
105
106
  // main.go still compiles (api would otherwise be declared-and-unused).
106
107
  function unpatchMainGo(mainGoPath, patch) {
107
108
  let content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
108
- const { importLine, modelImportLine, schemaLines, migrateLine, servicePrefix, routeLine } = mainGoLines(patch);
109
- content = (0, marker_patch_1.removeLines)(content, [importLine, modelImportLine, migrateLine, routeLine]);
110
- // by prefix: the service line may have grown arguments since it was written
111
- content = (0, marker_patch_1.removeLinesByPrefix)(content, [servicePrefix]);
109
+ const { importLine, modelImportLine, schemaLines, migrateLine, legacyServicePrefix, legacyRouteLine, routeLine } = mainGoLines(patch);
110
+ content = (0, marker_patch_1.removeLines)(content, [importLine, modelImportLine, migrateLine, routeLine, legacyRouteLine]);
111
+ // by prefix: remove the named service line from projects generated before
112
+ // composition became feature-local.
113
+ content = (0, marker_patch_1.removeLinesByPrefix)(content, [legacyServicePrefix]);
112
114
  // by contiguous block, not removeLines: every module's schema block is
113
115
  // identical except the schema name, so a line-by-line removal would also
114
116
  // strip another module's matching lines.
@@ -31,7 +31,13 @@ function ensureImport(content, importPath) {
31
31
  // the search argument is a plain string, not a regex — collapsing any "$$"
32
32
  // that happens to appear in importLine. A function's return value is spliced
33
33
  // in literally, so this holds regardless of what importPath contains.
34
- return content.replace(/import \(\n/, () => `import (\n\t${importLine}\n`);
34
+ if (content.includes("import (\n")) {
35
+ return content.replace(/import \(\n/, () => `import (\n\t${importLine}\n`);
36
+ }
37
+ // A minimal CQRS service has no imports until the first method is added.
38
+ // Create a complete block after the package clause so later generated
39
+ // methods can use the same idempotent path.
40
+ return content.replace(/^(package [^\n]+\n)/, (_match, packageLine) => `${packageLine}\nimport (\n\t${importLine}\n)\n`);
35
41
  }
36
42
  // insertBeforeMarkerOnce: like insertBeforeMarker but a no-op if `sentinel`
37
43
  // already appears in the file. Makes module wiring idempotent — re-running