@nakedev/go-scaffold 0.1.4 → 0.3.0
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.
- package/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
package/dist/commands/method.js
CHANGED
|
@@ -13,6 +13,7 @@ const module_location_1 = require("../utils/module-location");
|
|
|
13
13
|
const method_patcher_1 = require("../utils/method-patcher");
|
|
14
14
|
const gocheck_1 = require("../utils/gocheck");
|
|
15
15
|
const template_renderer_1 = require("../utils/template-renderer");
|
|
16
|
+
const migrations_1 = require("../utils/migrations");
|
|
16
17
|
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
17
18
|
const generate_wizard_1 = require("../prompts/generate-wizard");
|
|
18
19
|
// URL path registered in method-patcher.ts, excluding the project-wide API
|
|
@@ -63,7 +64,7 @@ function methodOpenapiDocument(naming, method, type, getMode, field) {
|
|
|
63
64
|
}
|
|
64
65
|
async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = process.cwd()) {
|
|
65
66
|
const config = (0, config_1.readConfig)(projectDir);
|
|
66
|
-
const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, moduleNameArg ?? (await (0, generate_wizard_1.
|
|
67
|
+
const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, moduleNameArg ?? (await (0, generate_wizard_1.promptExistingModule)((0, module_location_1.existingModulePackages)(projectDir), "add a method to")));
|
|
67
68
|
const modulePath = naming.pkg;
|
|
68
69
|
const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
|
|
69
70
|
const paths = {
|
|
@@ -88,10 +89,20 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
|
|
|
88
89
|
}
|
|
89
90
|
const getMode = type === "get" ? opts.getMode ?? (await (0, generate_wizard_1.promptGetMode)()) : undefined;
|
|
90
91
|
const field = type === "get" && getMode === "one" ? opts.field ?? (await (0, generate_wizard_1.promptLookupField)()) : undefined;
|
|
91
|
-
// field becomes a Go param name (`func (...)(ctx, <field> string)`)
|
|
92
|
+
// field becomes a Go param name (`func (...)(ctx, <field> string)`) and a
|
|
93
|
+
// column name in the generated `WHERE <field> = ?`
|
|
92
94
|
if (field)
|
|
93
|
-
(0, naming_1.
|
|
95
|
+
(0, naming_1.assertGoIdentifier)((0, naming_1.toCamelCase)(field), "lookup field");
|
|
94
96
|
const method = (0, naming_1.resolveMethodNaming)(methodNameArg ?? (await (0, generate_wizard_1.promptMethodName)()));
|
|
97
|
+
// Every marker this command patches has to exist before the first write:
|
|
98
|
+
// patchMethod writes dto.go, then handler.go, then reads service.go, so a
|
|
99
|
+
// missing service marker used to leave two files patched and the method
|
|
100
|
+
// permanently un-retryable (assertNotDuplicate then sees it as existing).
|
|
101
|
+
if (!(0, method_patcher_1.markersPresent)(paths.handlerPath, paths.servicePath)) {
|
|
102
|
+
throw new Error(`internal/app/${naming.pkg} is missing the marker comments \`generate method\` patches at.\n` +
|
|
103
|
+
`handler.go and service.go must both still carry their \`// go-scaffold:*\` markers —\n` +
|
|
104
|
+
`restore them, or add this method by hand.`);
|
|
105
|
+
}
|
|
95
106
|
const docsRelativePath = config.features.openapiDocs
|
|
96
107
|
? `${naming.plural}/methods/${method.pathSegment}.yaml`
|
|
97
108
|
: undefined;
|
|
@@ -102,6 +113,24 @@ async function generateMethod(moduleNameArg, methodNameArg, opts, projectDir = p
|
|
|
102
113
|
(0, method_patcher_1.patchMethod)(paths, naming, method, { type, getMode, field }, config.goModule);
|
|
103
114
|
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
104
115
|
(0, gocheck_1.assertNoDrift)(projectDir, checkBefore, config);
|
|
116
|
+
// A --field lookup queries a column the table doesn't have yet. GORM builds
|
|
117
|
+
// that SQL at runtime, so nothing before the first real request notices:
|
|
118
|
+
// the build passes, vet passes, the generated tests pass. Ship the column
|
|
119
|
+
// and its index with the code that needs them.
|
|
120
|
+
let fieldMigration = "";
|
|
121
|
+
if (field) {
|
|
122
|
+
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
123
|
+
fieldMigration = (0, migrations_1.newMigrationVersion)(migrationsDir);
|
|
124
|
+
const fieldColumn = (0, naming_1.toDbName)(field);
|
|
125
|
+
await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
|
|
126
|
+
{ template: "generate/module/field-column.up.sql.hbs", output: path_1.default.join("migrations", `${fieldMigration}_add_${naming.tableName}_${fieldColumn}.up.sql`) },
|
|
127
|
+
{ template: "generate/module/field-column.down.sql.hbs", output: path_1.default.join("migrations", `${fieldMigration}_add_${naming.tableName}_${fieldColumn}.down.sql`) },
|
|
128
|
+
], { ...naming, pkg: naming.pkg, methodName: method.name, fieldColumn });
|
|
129
|
+
}
|
|
130
|
+
if (fieldMigration) {
|
|
131
|
+
console.log(`migration: migrations/${fieldMigration}_add_${naming.tableName}_${(0, naming_1.toDbName)(field)}.{up,down}.sql ` +
|
|
132
|
+
`(adds the column the lookup queries, plus an index on it — check the type before applying)`);
|
|
133
|
+
}
|
|
105
134
|
if (docsRelativePath) {
|
|
106
135
|
const docsPath = path_1.default.join(projectDir, "docs", docsRelativePath);
|
|
107
136
|
fs_extra_1.default.outputFileSync(docsPath, methodOpenapiDocument(naming, method, type, getMode, field));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.addObservability = addObservability;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const config_1 = require("../utils/config");
|
|
11
|
+
const template_renderer_1 = require("../utils/template-renderer");
|
|
12
|
+
const observability_manifest_1 = require("../templates/observability-manifest");
|
|
13
|
+
const observability_patcher_1 = require("../utils/observability-patcher");
|
|
14
|
+
const gocheck_1 = require("../utils/gocheck");
|
|
15
|
+
const gomod_patcher_1 = require("../utils/gomod-patcher");
|
|
16
|
+
// addObservability scaffolds Prometheus metrics (GET /metrics) and
|
|
17
|
+
// OpenTelemetry tracing for Gin + GORM, wiring both into the files `create`
|
|
18
|
+
// already wrote. Opt-in and separate from `create`: most projects don't want
|
|
19
|
+
// to think about a collector on day one, and unlike worker/auth/rbac this
|
|
20
|
+
// touches files every project already has, so it has to patch rather than
|
|
21
|
+
// just add new ones. `create --observability` calls this immediately after
|
|
22
|
+
// scaffolding, so the two paths produce identical projects.
|
|
23
|
+
async function addObservability(projectDir = process.cwd(), opts = {}) {
|
|
24
|
+
const config = (0, config_1.readConfig)(projectDir);
|
|
25
|
+
const telemetryGoPath = path_1.default.join(projectDir, "internal", "platform", "telemetry", "tracing.go");
|
|
26
|
+
if (fs_extra_1.default.existsSync(telemetryGoPath)) {
|
|
27
|
+
throw new Error(`${telemetryGoPath} already exists — observability looks like it's already been added`);
|
|
28
|
+
}
|
|
29
|
+
const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
|
|
30
|
+
await (0, template_renderer_1.applyTemplateEntries)(projectDir, observability_manifest_1.OBSERVABILITY_FILES, { openapiDocs: config.features.openapiDocs });
|
|
31
|
+
(0, observability_patcher_1.patchMainGoForObservability)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.projectName);
|
|
32
|
+
(0, observability_patcher_1.patchDatabaseGoForObservability)(path_1.default.join(projectDir, "internal", "platform", "database", "database.go"), config.goModule);
|
|
33
|
+
(0, observability_patcher_1.patchConfigForObservability)(path_1.default.join(projectDir, "internal", "shared", "config", "config.go"));
|
|
34
|
+
(0, gomod_patcher_1.patchGoModRequires)(path_1.default.join(projectDir, "go.mod"), [
|
|
35
|
+
"github.com/prometheus/client_golang v1.24.1",
|
|
36
|
+
"go.opentelemetry.io/otel v1.45.0",
|
|
37
|
+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0",
|
|
38
|
+
"go.opentelemetry.io/otel/sdk v1.45.0",
|
|
39
|
+
"go.opentelemetry.io/otel/trace v1.45.0",
|
|
40
|
+
]);
|
|
41
|
+
(0, observability_patcher_1.patchEnvExampleForObservability)(path_1.default.join(projectDir, ".env.example"));
|
|
42
|
+
const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
|
|
43
|
+
if (config.features.openapiDocs && fs_extra_1.default.existsSync(openapiPath)) {
|
|
44
|
+
(0, observability_patcher_1.patchOpenapiIndexForObservability)(openapiPath);
|
|
45
|
+
}
|
|
46
|
+
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
47
|
+
// parse-only: the otel/prometheus imports this adds aren't in go.mod until
|
|
48
|
+
// the `go mod tidy` printed below, so `go vet` can't be the gate here.
|
|
49
|
+
(0, gocheck_1.assertStillParses)(projectDir, parsedBefore, "added observability");
|
|
50
|
+
// After the gate, next to writeConfig: these are .md files, so the parse
|
|
51
|
+
// check has no reason to guard them, and a throw from it used to strand the
|
|
52
|
+
// docs saying `enabled` while the config it never reached still said false —
|
|
53
|
+
// a disagreement no later command could converge, since the "already added"
|
|
54
|
+
// guard then blocks a re-run.
|
|
55
|
+
const staleDocs = refreshArchitectDocs(projectDir, config);
|
|
56
|
+
(0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, observability: true } });
|
|
57
|
+
if (opts.silent)
|
|
58
|
+
return;
|
|
59
|
+
console.log(picocolors_1.default.green("\nadded internal/platform/telemetry/, internal/shared/middleware/{metrics,tracing}.go, and GET /metrics"));
|
|
60
|
+
console.log("wired into cmd/api/wiring.go and internal/platform/database — every request and GORM query now gets a trace span");
|
|
61
|
+
if (staleDocs.length) {
|
|
62
|
+
// Deliberately not "you edited these". The comparison is a whole-file
|
|
63
|
+
// match against today's template, and techstack.md embeds pinned
|
|
64
|
+
// dependency versions — so every release that bumps one makes every
|
|
65
|
+
// project scaffolded before it look edited. Which is exactly the
|
|
66
|
+
// population this function exists for.
|
|
67
|
+
console.log(picocolors_1.default.yellow(`\nnote: couldn't safely rewrite ${staleDocs.join(" and ")} — ` +
|
|
68
|
+
`${staleDocs.length > 1 ? "they don't" : "it doesn't"} match what this go-scaffold\n` +
|
|
69
|
+
` would have generated, so ${staleDocs.length > 1 ? "they were" : "it was"} left alone rather than overwriting your edits.\n` +
|
|
70
|
+
` ${staleDocs.length > 1 ? "They still describe" : "It still describes"} this project as having no metrics or tracing; update by hand.`));
|
|
71
|
+
}
|
|
72
|
+
console.log(picocolors_1.default.dim("\nnext: go mod tidy, then set OTEL_EXPORTER_OTLP_ENDPOINT to export traces (empty = tracing no-ops, /metrics works either way)"));
|
|
73
|
+
}
|
|
74
|
+
// The architecture docs gate their observability sections on a `create`-time
|
|
75
|
+
// flag, so adding the feature afterwards used to leave techstack.md saying
|
|
76
|
+
// `disabled` and architecture.md missing the section entirely — while the
|
|
77
|
+
// README promises `create --observability` and this command produce the same
|
|
78
|
+
// project.
|
|
79
|
+
//
|
|
80
|
+
// Re-rendering from the templates rather than string-patching keeps the prose
|
|
81
|
+
// in one place (the .hbs), but would also silently discard a user's edits. So
|
|
82
|
+
// it first renders what the file *should* look like today, with observability
|
|
83
|
+
// still off: only a byte-for-byte match proves nobody has touched it. Returns
|
|
84
|
+
// the docs it declined to overwrite, for the caller to warn about.
|
|
85
|
+
function refreshArchitectDocs(projectDir, config) {
|
|
86
|
+
const docs = [
|
|
87
|
+
{ template: "create/features/docs/architecture.md.hbs", output: path_1.default.join("docs", "architect", "architecture.md") },
|
|
88
|
+
{ template: "create/features/docs/techstack.md.hbs", output: path_1.default.join("docs", "architect", "techstack.md") },
|
|
89
|
+
];
|
|
90
|
+
// only what the two templates actually reference — projectName, apiPrefix
|
|
91
|
+
// and the feature flags
|
|
92
|
+
const base = { projectName: config.projectName, apiPrefix: config.apiPrefix, ...config.features };
|
|
93
|
+
const root = (0, template_renderer_1.getTemplatesRoot)();
|
|
94
|
+
const skipped = [];
|
|
95
|
+
for (const doc of docs) {
|
|
96
|
+
const outputPath = path_1.default.join(projectDir, doc.output);
|
|
97
|
+
if (!fs_extra_1.default.existsSync(outputPath))
|
|
98
|
+
continue;
|
|
99
|
+
const source = fs_extra_1.default.readFileSync(path_1.default.join(root, doc.template), "utf8");
|
|
100
|
+
// compare with line endings normalised — a Windows checkout with
|
|
101
|
+
// core.autocrlf=true would otherwise never match, and the feature would
|
|
102
|
+
// silently never fire there
|
|
103
|
+
const onDisk = lf(fs_extra_1.default.readFileSync(outputPath, "utf8"));
|
|
104
|
+
if (onDisk !== lf((0, template_renderer_1.renderString)(source, { ...base, observability: false }))) {
|
|
105
|
+
skipped.push(doc.output);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
fs_extra_1.default.writeFileSync(outputPath, (0, template_renderer_1.renderString)(source, { ...base, observability: true }));
|
|
109
|
+
}
|
|
110
|
+
return skipped;
|
|
111
|
+
}
|
|
112
|
+
function lf(text) {
|
|
113
|
+
return text.replace(/\r\n/g, "\n");
|
|
114
|
+
}
|
package/dist/commands/rbac.js
CHANGED
|
@@ -13,6 +13,8 @@ const rbac_manifest_1 = require("../templates/rbac-manifest");
|
|
|
13
13
|
const migrations_1 = require("../utils/migrations");
|
|
14
14
|
const rbac_patcher_1 = require("../utils/rbac-patcher");
|
|
15
15
|
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
16
|
+
const golangci_patcher_1 = require("../utils/golangci-patcher");
|
|
17
|
+
const gocheck_1 = require("../utils/gocheck");
|
|
16
18
|
// URL (relative to the api prefix) -> docs file (relative to docs/) for every
|
|
17
19
|
// route `add rbac` registers or adds onto the user handler.
|
|
18
20
|
const RBAC_OPENAPI_PATHS = [
|
|
@@ -41,6 +43,12 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
41
43
|
if (fs_extra_1.default.existsSync(roleDir)) {
|
|
42
44
|
throw new Error(`${roleDir} already exists — RBAC looks like it's already been added`);
|
|
43
45
|
}
|
|
46
|
+
const before = (0, gocheck_1.typeChecks)(projectDir);
|
|
47
|
+
// Every file below is patched in place, and main.go's userSvc line is the
|
|
48
|
+
// one rbac cannot construct on its own — check it first so a mismatch costs
|
|
49
|
+
// an error message rather than a half-patched project.
|
|
50
|
+
(0, rbac_patcher_1.assertRbacPatchable)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.features.authStore ?? "redis", config.features.worker ?? false);
|
|
51
|
+
const parsedBefore = (0, gocheck_1.parseChecks)(projectDir);
|
|
44
52
|
await (0, template_renderer_1.applyTemplateEntries)(projectDir, rbac_manifest_1.RBAC_FILES, { goModule: config.goModule });
|
|
45
53
|
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
46
54
|
fs_extra_1.default.ensureDirSync(migrationsDir);
|
|
@@ -58,7 +66,9 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
58
66
|
(0, rbac_patcher_1.patchUserDTOForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "dto.go"));
|
|
59
67
|
(0, rbac_patcher_1.patchUserHandlerForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "handler.go"), config.goModule);
|
|
60
68
|
(0, rbac_patcher_1.patchUserErrorsForRbac)(path_1.default.join(projectDir, "internal", "app", "user", "errors.go"));
|
|
61
|
-
(0,
|
|
69
|
+
(0, golangci_patcher_1.patchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), config.goModule, "role");
|
|
70
|
+
// projects scaffolded before --store existed are all Redis-backed
|
|
71
|
+
(0, rbac_patcher_1.patchMainGoForRbac)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), config.goModule, config.features.authStore ?? "redis", config.features.worker ?? false);
|
|
62
72
|
(0, rbac_patcher_1.patchCmdSeedForRbac)(path_1.default.join(projectDir, "cmd", "seed", "main.go"), config.goModule);
|
|
63
73
|
patchEnvExample(path_1.default.join(projectDir, ".env.example"));
|
|
64
74
|
let docsMessage = "";
|
|
@@ -77,9 +87,16 @@ async function addRbac(projectDir = process.cwd()) {
|
|
|
77
87
|
docsMessage = "\ndocs: docs/rbac/*.yaml, wired into docs/openapi.yaml";
|
|
78
88
|
}
|
|
79
89
|
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
90
|
+
(0, gocheck_1.assertStillParses)(projectDir, parsedBefore, "added RBAC");
|
|
91
|
+
// rbac introduces no new third-party dependency, so unlike add auth/worker
|
|
92
|
+
// this can hold the stronger line: the project has to still type-check.
|
|
93
|
+
(0, gocheck_1.assertNoDrift)(projectDir, before, config, {
|
|
94
|
+
didWhat: "added RBAC",
|
|
95
|
+
recover: "internal/app/role/ and the rbac patches were left in place — reconcile cmd/api/wiring.go by hand.",
|
|
96
|
+
});
|
|
80
97
|
(0, config_1.writeConfig)(projectDir, { ...config, features: { ...config.features, rbac: true } });
|
|
81
98
|
console.log(picocolors_1.default.green("\nadded internal/app/role/ and internal/shared/middleware/authz.go"));
|
|
82
|
-
console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/
|
|
99
|
+
console.log("registered GET /users, GET /users/:id, PATCH /users/:id/set-role, /roles, and /permissions in cmd/api/wiring.go" + docsMessage);
|
|
83
100
|
console.log(picocolors_1.default.yellow("\n⚠ AUTO_MIGRATE=true does NOT seed the role/permission data — it only creates the\n" +
|
|
84
101
|
" tables from the Go structs. The \"staff\"/\"admin\" roles and their permissions live\n" +
|
|
85
102
|
" in the migration's SQL (INSERT statements), which AutoMigrate never runs. Without\n" +
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.undoModule = undoModule;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
12
|
+
const config_1 = require("../utils/config");
|
|
13
|
+
const naming_1 = require("../utils/naming");
|
|
14
|
+
const module_location_1 = require("../utils/module-location");
|
|
15
|
+
const main_patcher_1 = require("../utils/main-patcher");
|
|
16
|
+
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
17
|
+
const golangci_patcher_1 = require("../utils/golangci-patcher");
|
|
18
|
+
const template_renderer_1 = require("../utils/template-renderer");
|
|
19
|
+
const gocheck_1 = require("../utils/gocheck");
|
|
20
|
+
const generate_wizard_1 = require("../prompts/generate-wizard");
|
|
21
|
+
// undoModule reverses a `generate module` that shouldn't have happened —
|
|
22
|
+
// a typo'd name, a domain you decided against — by deleting everything it
|
|
23
|
+
// created, migration files included.
|
|
24
|
+
//
|
|
25
|
+
// It deliberately does NOT try to retire a domain that is live somewhere.
|
|
26
|
+
// The command this replaced claimed to ("a migration may already be recorded
|
|
27
|
+
// in schema_migrations on production"), and that framing was wrong twice
|
|
28
|
+
// over. Nobody decommissions a shipped domain by running a scaffolding CLI:
|
|
29
|
+
// that needs a deprecation window, a data migration, and a drop migration
|
|
30
|
+
// written by hand. Meanwhile the case people actually hit — undoing a module
|
|
31
|
+
// generated thirty seconds ago — got the production treatment, so the typo's
|
|
32
|
+
// migration survived forever. `migrations/embed.go` is a `//go:embed *`, so
|
|
33
|
+
// that leftover then ran on every database anyone created from then on,
|
|
34
|
+
// building a `oder_svc.oders` table nobody asked for.
|
|
35
|
+
//
|
|
36
|
+
// So: work out whether the migration could possibly have escaped this
|
|
37
|
+
// machine. If it can't have, delete it. If it might have, refuse and say why.
|
|
38
|
+
async function undoModule(rawName, opts, projectDir = process.cwd()) {
|
|
39
|
+
const config = (0, config_1.readConfig)(projectDir);
|
|
40
|
+
const naming = (0, module_location_1.resolveProjectModuleNaming)(projectDir, rawName ?? (await (0, generate_wizard_1.promptExistingModule)((0, module_location_1.existingModulePackages)(projectDir), "undo")));
|
|
41
|
+
const modulePath = naming.pkg;
|
|
42
|
+
const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
|
|
43
|
+
if (!fs_extra_1.default.existsSync(moduleDir)) {
|
|
44
|
+
throw new Error(`module "${naming.pkg}" not found at internal/app/${modulePath} — nothing to undo`);
|
|
45
|
+
}
|
|
46
|
+
// `user` and `role` live under internal/app/ like any generated module, but
|
|
47
|
+
// they were wired by add auth / add rbac using entirely different line
|
|
48
|
+
// shapes — this command would delete the package and then silently fail to
|
|
49
|
+
// un-wire most of it, leaving main.go and cmd/seed referencing a package
|
|
50
|
+
// that no longer exists. findDependents can't catch it either: the
|
|
51
|
+
// consumer-side-interface convention means `user` never imports `role`.
|
|
52
|
+
const featureOwner = { user: "auth", role: "rbac" };
|
|
53
|
+
const owner = featureOwner[modulePath];
|
|
54
|
+
if (owner && config.features[owner]) {
|
|
55
|
+
throw new Error(`"${modulePath}" belongs to \`go-scaffold add ${owner}\`, not to \`generate module\` — refusing to undo it.\n` +
|
|
56
|
+
`Its wiring in cmd/api/wiring.go (and cmd/seed, docs/openapi.yaml) doesn't match what this command knows how to reverse,\n` +
|
|
57
|
+
`so removing it would leave the project un-compilable. Undo \`add ${owner}\` by hand, or start from a fresh scaffold.`);
|
|
58
|
+
}
|
|
59
|
+
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
60
|
+
const migrations = moduleMigrations(migrationsDir, (0, naming_1.migrationSlugAliases)(naming));
|
|
61
|
+
const { checkedDatabase } = assertMigrationsNeverEscaped(projectDir, migrations, naming.pkg);
|
|
62
|
+
if (!opts.yes) {
|
|
63
|
+
const ok = await (0, prompts_1.confirm)({
|
|
64
|
+
message: `Undo module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its docs, ` +
|
|
65
|
+
`${migrations.length ? `${migrations.length} migration file(s), ` : ""}` +
|
|
66
|
+
`and un-wires wiring.go/openapi.yaml. The ${naming.tableName} table itself is not dropped.`,
|
|
67
|
+
default: false,
|
|
68
|
+
});
|
|
69
|
+
if (!ok)
|
|
70
|
+
throw new Error("undo cancelled");
|
|
71
|
+
}
|
|
72
|
+
// detect --auth/--permission from the generated handler.go itself (not
|
|
73
|
+
// stored anywhere else) — unpatchMainGo needs the exact same flags used at
|
|
74
|
+
// generate-time to reconstruct the identical line it's removing.
|
|
75
|
+
const handlerGoPath = path_1.default.join(moduleDir, "handler.go");
|
|
76
|
+
let auth;
|
|
77
|
+
let permission;
|
|
78
|
+
if (fs_extra_1.default.existsSync(handlerGoPath)) {
|
|
79
|
+
const handlerContent = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
|
|
80
|
+
auth = /jwtSecret\s+string/.test(handlerContent);
|
|
81
|
+
permission = handlerContent.match(/h\.authz\.Require\("([^"]+)"\)/)?.[1];
|
|
82
|
+
}
|
|
83
|
+
// Refuse if another domain still imports this one. Deleting it anyway
|
|
84
|
+
// leaves the project un-compilable, and the error Go reports then points at
|
|
85
|
+
// the surviving module rather than at the removal that caused it.
|
|
86
|
+
const dependents = findDependents(projectDir, config.goModule, modulePath);
|
|
87
|
+
if (dependents.length > 0) {
|
|
88
|
+
throw new Error(`module "${naming.pkg}" is still used by: ${dependents.join(", ")}\n` +
|
|
89
|
+
`remove those references (or the modules themselves) first — deleting internal/app/${modulePath} now would break the build`);
|
|
90
|
+
}
|
|
91
|
+
const before = (0, gocheck_1.typeChecks)(projectDir);
|
|
92
|
+
// Un-wire before deleting, not after. Every step below can throw — a
|
|
93
|
+
// hand-edited wiring.go with the routes marker gone is enough — and if the
|
|
94
|
+
// package is already gone by then, the user's own code inside it is
|
|
95
|
+
// unrecoverable outside of git. This order fails the other way round: a
|
|
96
|
+
// still-present package with its wiring removed, which `generate module`
|
|
97
|
+
// re-wires idempotently.
|
|
98
|
+
// 1. wiring.go
|
|
99
|
+
(0, main_patcher_1.unpatchMainGo)(path_1.default.join(projectDir, "cmd", "api", "wiring.go"), {
|
|
100
|
+
goModule: config.goModule,
|
|
101
|
+
modulePath,
|
|
102
|
+
pkg: naming.pkg,
|
|
103
|
+
pascalName: naming.pascalName,
|
|
104
|
+
schemaName: naming.schemaName,
|
|
105
|
+
auth,
|
|
106
|
+
permission,
|
|
107
|
+
});
|
|
108
|
+
(0, golangci_patcher_1.unpatchGolangciForModule)(path_1.default.join(projectDir, ".golangci.yml"), modulePath);
|
|
109
|
+
// 2. openapi index + per-module docs
|
|
110
|
+
const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
|
|
111
|
+
if (fs_extra_1.default.existsSync(openapiPath)) {
|
|
112
|
+
(0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
|
|
113
|
+
fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
|
|
114
|
+
}
|
|
115
|
+
// 3. the migrations — safe to delete, assertMigrationsNeverEscaped proved
|
|
116
|
+
// above that they exist nowhere but this working tree
|
|
117
|
+
for (const file of migrations)
|
|
118
|
+
fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, file));
|
|
119
|
+
// 4. the domain package — last, once nothing left can fail
|
|
120
|
+
fs_extra_1.default.removeSync(moduleDir);
|
|
121
|
+
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
122
|
+
(0, gocheck_1.assertNoDrift)(projectDir, before, config, {
|
|
123
|
+
didWhat: `undid module "${naming.pkg}"`,
|
|
124
|
+
recover: `internal/app/${modulePath}/ is gone; re-run \`go-scaffold generate module ${naming.pkg}\` to put it\nback, then reconcile cmd/api/wiring.go by hand.`,
|
|
125
|
+
});
|
|
126
|
+
console.log(picocolors_1.default.green(`\nundid module "${naming.pkg}"`));
|
|
127
|
+
console.log(` deleted internal/app/${modulePath}/`);
|
|
128
|
+
console.log(` un-wired cmd/api/wiring.go`);
|
|
129
|
+
if (fs_extra_1.default.existsSync(openapiPath))
|
|
130
|
+
console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
|
|
131
|
+
if (migrations.length)
|
|
132
|
+
console.log(` deleted migrations/${migrations.join(", migrations/")}`);
|
|
133
|
+
if (migrations.length && !checkedDatabase) {
|
|
134
|
+
console.log(picocolors_1.default.yellow(`\ncouldn't check whether those migrations had already been applied — no reachable DB_DSN,\n` +
|
|
135
|
+
`or no migrate CLI. They weren't committed, so no other environment can have them, but if\n` +
|
|
136
|
+
`you had run them against a local database it now records a version with no file behind it.`));
|
|
137
|
+
}
|
|
138
|
+
console.log(picocolors_1.default.dim(`\nnothing was dropped from any database — if you had already run these migrations locally,\n` +
|
|
139
|
+
`the ${naming.tableName} table is still there. \`make db-drop && make db-create && make migrate-up\` is the\n` +
|
|
140
|
+
`quickest way back to a clean dev database.`));
|
|
141
|
+
}
|
|
142
|
+
// moduleMigrations lists the migration files `generate module` created for
|
|
143
|
+
// this module: the create pair, plus the permission pair when it was
|
|
144
|
+
// generated with --permission.
|
|
145
|
+
function moduleMigrations(migrationsDir, slugs) {
|
|
146
|
+
if (!fs_extra_1.default.existsSync(migrationsDir))
|
|
147
|
+
return [];
|
|
148
|
+
return fs_extra_1.default
|
|
149
|
+
.readdirSync(migrationsDir)
|
|
150
|
+
.filter((f) => slugs.some((slug) => f.endsWith(`_create_${slug}.up.sql`) ||
|
|
151
|
+
f.endsWith(`_create_${slug}.down.sql`) ||
|
|
152
|
+
f.endsWith(`_add_${slug}_permission.up.sql`) ||
|
|
153
|
+
f.endsWith(`_add_${slug}_permission.down.sql`)))
|
|
154
|
+
.sort();
|
|
155
|
+
}
|
|
156
|
+
// assertMigrationsNeverEscaped is what lets undo delete migration files at
|
|
157
|
+
// all. A migration that has only ever existed in this working tree cannot be
|
|
158
|
+
// recorded in any other environment's schema_migrations, so removing it can't
|
|
159
|
+
// make two databases disagree. Two ways it could have escaped:
|
|
160
|
+
//
|
|
161
|
+
// 1. it's committed — then it's in whatever anyone pulled or deployed
|
|
162
|
+
// 2. it's applied to the database this project is pointed at
|
|
163
|
+
//
|
|
164
|
+
// A project with no git repository at all has never been pushed anywhere, so
|
|
165
|
+
// "git doesn't know this file" covers it.
|
|
166
|
+
//
|
|
167
|
+
// The git half is the one that has to be right: it's what decides whether a
|
|
168
|
+
// migration could be in someone else's database. The database half only ever
|
|
169
|
+
// protects the developer's own dev database, and that damage is recoverable
|
|
170
|
+
// (`migrate force`), so an unreachable database doesn't block the command —
|
|
171
|
+
// it reports back that it couldn't check, and the caller says so.
|
|
172
|
+
function assertMigrationsNeverEscaped(projectDir, migrations, pkg) {
|
|
173
|
+
if (migrations.length === 0)
|
|
174
|
+
return { checkedDatabase: true };
|
|
175
|
+
const known = migrations.filter((f) => gitKnowsAbout(projectDir, path_1.default.join("migrations", f)));
|
|
176
|
+
if (known.length) {
|
|
177
|
+
throw new Error(`git knows about these migrations, so they may already have been applied somewhere:\n` +
|
|
178
|
+
known.map((f) => ` migrations/${f}`).join("\n") +
|
|
179
|
+
`\n\n\`undo\` is for a module you generated and immediately regretted — it deletes migration\n` +
|
|
180
|
+
`files, which is only safe while they exist nowhere but this working tree. To retire a domain\n` +
|
|
181
|
+
`that has shipped, leave its history alone and write the reversal explicitly:\n` +
|
|
182
|
+
` go-scaffold generate migration drop_${pkg}`);
|
|
183
|
+
}
|
|
184
|
+
// Check every database this project could mean, not just the first one
|
|
185
|
+
// found: an exported DB_DSN left over in the shell would otherwise shadow
|
|
186
|
+
// the project's own .env and answer for the wrong database entirely.
|
|
187
|
+
let checkedDatabase = true;
|
|
188
|
+
for (const dsn of candidateDsns(projectDir)) {
|
|
189
|
+
const applied = appliedVersion(projectDir, dsn);
|
|
190
|
+
if (applied === null) {
|
|
191
|
+
checkedDatabase = false;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const escaped = migrations.filter((f) => Number(f.split("_")[0]) <= applied);
|
|
195
|
+
if (escaped.length) {
|
|
196
|
+
throw new Error(`these migrations have already been applied to your database (schema_migrations is at ${applied}):\n` +
|
|
197
|
+
escaped.map((f) => ` migrations/${f}`).join("\n") +
|
|
198
|
+
`\n\nDeleting the files now would leave the database recorded at a version with no migration\n` +
|
|
199
|
+
`behind it, and golang-migrate would refuse to move from there. Roll them back first:\n` +
|
|
200
|
+
` migrate -path migrations -database "$DB_DSN" down ${escaped.length / 2 || 1}\n` +
|
|
201
|
+
`then run this again.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { checkedDatabase };
|
|
205
|
+
}
|
|
206
|
+
// gitKnowsAbout asks both questions, because either one means the file has
|
|
207
|
+
// left this working tree. The index alone isn't enough: a file that was
|
|
208
|
+
// committed and later `git rm --cached`'d is untracked now but is still in
|
|
209
|
+
// history, and in anything anyone has pulled.
|
|
210
|
+
function gitKnowsAbout(projectDir, relativePath) {
|
|
211
|
+
if (runsClean(projectDir, ["ls-files", "--error-unmatch", relativePath]))
|
|
212
|
+
return true;
|
|
213
|
+
const inHistory = (0, child_process_1.spawnSync)("git", ["log", "--all", "--max-count=1", "--format=%H", "--", relativePath], {
|
|
214
|
+
cwd: projectDir,
|
|
215
|
+
encoding: "utf8",
|
|
216
|
+
});
|
|
217
|
+
return !inHistory.error && inHistory.status === 0 && inHistory.stdout.trim() !== "";
|
|
218
|
+
}
|
|
219
|
+
function runsClean(projectDir, args) {
|
|
220
|
+
try {
|
|
221
|
+
(0, child_process_1.execFileSync)("git", args, { cwd: projectDir, stdio: "ignore" });
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// not tracked, or not a git repository at all
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// appliedVersion reads schema_migrations through the migrate CLI the project
|
|
230
|
+
// already documents. Returns null when it can't tell — no migrate on PATH, no
|
|
231
|
+
// DB_DSN, database unreachable, or nothing applied yet.
|
|
232
|
+
function appliedVersion(projectDir, dsn) {
|
|
233
|
+
// spawnSync, not execFileSync: `migrate version` prints the version on
|
|
234
|
+
// stderr and still exits 0, and execFileSync only hands back stdout. That
|
|
235
|
+
// read as "" -> Number("") -> 0 -> "nothing applied yet", which waved every
|
|
236
|
+
// migration straight through the check this function exists to perform.
|
|
237
|
+
//
|
|
238
|
+
// The timeout matters as much: a DSN pointing at an unroutable host makes
|
|
239
|
+
// migrate sit on the OS TCP timeout, and the CLI froze for 77 seconds with
|
|
240
|
+
// no output before carrying on anyway.
|
|
241
|
+
const res = (0, child_process_1.spawnSync)("migrate", ["-path", "migrations", "-database", dsn, "version"], {
|
|
242
|
+
cwd: projectDir,
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
timeout: 5000,
|
|
245
|
+
});
|
|
246
|
+
if (res.error)
|
|
247
|
+
return null; // no migrate on PATH, or it timed out
|
|
248
|
+
const output = `${res.stderr ?? ""}\n${res.stdout ?? ""}`;
|
|
249
|
+
// a database that has never been migrated is a definitive "nothing
|
|
250
|
+
// applied", not an inconclusive answer
|
|
251
|
+
if (/no migration/i.test(output))
|
|
252
|
+
return 0;
|
|
253
|
+
return parseVersion(output);
|
|
254
|
+
}
|
|
255
|
+
// parseVersion pulls the bare version number out of migrate's output, and
|
|
256
|
+
// insists on actually finding one: anything else (a connection error, a
|
|
257
|
+
// "dirty" marker, an empty string) has to read as "can't tell", never as 0.
|
|
258
|
+
function parseVersion(output) {
|
|
259
|
+
const match = output.trim().match(/^(\d+)/m);
|
|
260
|
+
return match ? Number(match[1]) : null;
|
|
261
|
+
}
|
|
262
|
+
// dsnFromEnvFile reads DB_DSN out of .env the same way the generated Makefile
|
|
263
|
+
// does, and tolerates the two spellings people actually write: a quoted value,
|
|
264
|
+
// and a leading `export`. Getting either wrong doesn't fail loudly — it hands
|
|
265
|
+
// `migrate` a DSN it can't parse, appliedVersion then reports "can't tell",
|
|
266
|
+
// and the applied-migrations guard is skipped entirely. Both forms silently
|
|
267
|
+
// defeated it.
|
|
268
|
+
// candidateDsns returns every database this project might mean, deduped. Both
|
|
269
|
+
// are checked rather than just the first: an exported DB_DSN pointing at some
|
|
270
|
+
// other environment would otherwise shadow the project's own .env and answer
|
|
271
|
+
// the applied-migrations question about the wrong database.
|
|
272
|
+
function candidateDsns(projectDir) {
|
|
273
|
+
const found = [process.env.DB_DSN, dsnFromEnvFile(projectDir)].filter((d) => Boolean(d));
|
|
274
|
+
return [...new Set(found)];
|
|
275
|
+
}
|
|
276
|
+
function dsnFromEnvFile(projectDir) {
|
|
277
|
+
const envPath = path_1.default.join(projectDir, ".env");
|
|
278
|
+
if (!fs_extra_1.default.existsSync(envPath))
|
|
279
|
+
return undefined;
|
|
280
|
+
const match = fs_extra_1.default.readFileSync(envPath, "utf8").match(/^[ \t]*(?:export[ \t]+)?DB_DSN[ \t]*=[ \t]*(.*)$/m);
|
|
281
|
+
if (!match)
|
|
282
|
+
return undefined;
|
|
283
|
+
let value = match[1].trim();
|
|
284
|
+
const quote = value[0];
|
|
285
|
+
if (quote === '"' || quote === "'") {
|
|
286
|
+
// a quoted value ends at its closing quote; anything after it is a comment
|
|
287
|
+
const end = value.indexOf(quote, 1);
|
|
288
|
+
value = end === -1 ? value.slice(1) : value.slice(1, end);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// unquoted: strip a trailing ` # comment`, matching the Makefile's
|
|
292
|
+
// sed -E 's/[[:space:]]+#.*$//'. Not a bare `#` — that's legal in a URL.
|
|
293
|
+
value = value.replace(/[ \t]+#.*$/, "").trim();
|
|
294
|
+
}
|
|
295
|
+
return value || undefined;
|
|
296
|
+
}
|
|
297
|
+
// findDependents lists the .go files in *other* domains that import this one.
|
|
298
|
+
// Import path match, not a bare package-name match: `orders` appears in plenty
|
|
299
|
+
// of strings and comments, but only an import of
|
|
300
|
+
// "<goModule>/internal/app/orders" is a real compile-time dependency.
|
|
301
|
+
function findDependents(projectDir, goModule, modulePath) {
|
|
302
|
+
// The package itself, or one of its subpackages — and nothing else. Dropping
|
|
303
|
+
// the closing quote to catch `/model` was matching on a bare prefix too, so
|
|
304
|
+
// `undo module order` was refused by every module whose name merely starts
|
|
305
|
+
// with it: internal/app/orderitem imports nothing from internal/app/order,
|
|
306
|
+
// but the second path contains the first.
|
|
307
|
+
const base = `"${goModule}/internal/app/${modulePath}`;
|
|
308
|
+
const importsIt = (src) => src.includes(`${base}"`) || src.includes(`${base}/`);
|
|
309
|
+
const appDir = path_1.default.join(projectDir, "internal", "app");
|
|
310
|
+
const hits = [];
|
|
311
|
+
for (const pkg of (0, module_location_1.existingModulePackages)(projectDir)) {
|
|
312
|
+
if (pkg === modulePath)
|
|
313
|
+
continue;
|
|
314
|
+
for (const file of goFilesIn(path_1.default.join(appDir, pkg))) {
|
|
315
|
+
if (importsIt(fs_extra_1.default.readFileSync(file, "utf8"))) {
|
|
316
|
+
hits.push(path_1.default.relative(projectDir, file));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return hits;
|
|
321
|
+
}
|
|
322
|
+
function goFilesIn(dir) {
|
|
323
|
+
if (!fs_extra_1.default.existsSync(dir))
|
|
324
|
+
return [];
|
|
325
|
+
return fs_extra_1.default.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
326
|
+
const full = path_1.default.join(dir, entry.name);
|
|
327
|
+
if (entry.isDirectory())
|
|
328
|
+
return goFilesIn(full);
|
|
329
|
+
return entry.name.endsWith(".go") ? [full] : [];
|
|
330
|
+
});
|
|
331
|
+
}
|