@ductape/cli 0.2.23 → 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.
Files changed (101) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +7 -0
  3. package/dist/commands/migrate-codebase.d.ts +176 -0
  4. package/dist/commands/migrate-codebase.js +753 -0
  5. package/dist/commands/migration-artifact.d.ts +32 -0
  6. package/dist/commands/migration-artifact.js +48 -0
  7. package/dist/commands/migration-assurance.d.ts +10 -0
  8. package/dist/commands/migration-assurance.js +120 -0
  9. package/dist/commands/migration-database.d.ts +21 -0
  10. package/dist/commands/migration-database.js +162 -0
  11. package/dist/commands/migration-e2e.d.ts +68 -0
  12. package/dist/commands/migration-e2e.js +189 -0
  13. package/dist/commands/migration-environments.d.ts +20 -0
  14. package/dist/commands/migration-environments.js +58 -0
  15. package/dist/commands/migration-generated.d.ts +68 -0
  16. package/dist/commands/migration-generated.js +217 -0
  17. package/dist/commands/migration-portfolio.d.ts +88 -0
  18. package/dist/commands/migration-portfolio.js +223 -0
  19. package/dist/commands/migration-products.d.ts +23 -0
  20. package/dist/commands/migration-products.js +88 -0
  21. package/dist/commands/migration-review.d.ts +248 -0
  22. package/dist/commands/migration-review.js +624 -0
  23. package/dist/commands/migration-secrets.d.ts +24 -0
  24. package/dist/commands/migration-secrets.js +93 -0
  25. package/dist/commands/migration-slice.d.ts +132 -0
  26. package/dist/commands/migration-slice.js +266 -0
  27. package/dist/commands/migration-verification.d.ts +12 -0
  28. package/dist/commands/migration-verification.js +194 -0
  29. package/dist/commands/products.d.ts +1 -0
  30. package/dist/commands/products.js +33 -1
  31. package/dist/commands/secrets.d.ts +8 -0
  32. package/dist/commands/secrets.js +44 -0
  33. package/dist/index.js +354 -2
  34. package/dist/lib/migration-artifact.d.ts +13 -0
  35. package/dist/lib/migration-artifact.js +103 -0
  36. package/package.json +8 -1
  37. package/API_PARITY.md +0 -58
  38. package/DB_MIGRATE_PLAN.md +0 -223
  39. package/ductape.example.ts +0 -18
  40. package/src/commands/apply.ts +0 -153
  41. package/src/commands/apps-import.ts +0 -43
  42. package/src/commands/apps.ts +0 -92
  43. package/src/commands/cloud.ts +0 -68
  44. package/src/commands/completion.ts +0 -119
  45. package/src/commands/db-migrate.ts +0 -209
  46. package/src/commands/db-schema.ts +0 -429
  47. package/src/commands/db.ts +0 -63
  48. package/src/commands/generate.ts +0 -74
  49. package/src/commands/graph.ts +0 -51
  50. package/src/commands/init.ts +0 -39
  51. package/src/commands/install.ts +0 -76
  52. package/src/commands/link.ts +0 -84
  53. package/src/commands/login.ts +0 -145
  54. package/src/commands/logout.ts +0 -7
  55. package/src/commands/platform.ts +0 -123
  56. package/src/commands/products.ts +0 -228
  57. package/src/commands/profiles.ts +0 -23
  58. package/src/commands/resources.ts +0 -213
  59. package/src/commands/secrets.ts +0 -46
  60. package/src/commands/unlink.ts +0 -15
  61. package/src/commands/whoami.ts +0 -27
  62. package/src/commands/workspaces.ts +0 -98
  63. package/src/index.ts +0 -523
  64. package/src/lib/api-client.ts +0 -88
  65. package/src/lib/app-import.ts +0 -82
  66. package/src/lib/apply-loaders.ts +0 -30
  67. package/src/lib/config.ts +0 -230
  68. package/src/lib/context-store.ts +0 -77
  69. package/src/lib/db-methods.ts +0 -56
  70. package/src/lib/db-types.ts +0 -104
  71. package/src/lib/encryption.ts +0 -12
  72. package/src/lib/forms/enums.ts +0 -52
  73. package/src/lib/forms/index.ts +0 -8
  74. package/src/lib/forms/prompt.ts +0 -209
  75. package/src/lib/forms/schemas.ts +0 -288
  76. package/src/lib/forms/types.ts +0 -27
  77. package/src/lib/http.ts +0 -76
  78. package/src/lib/integrations.ts +0 -110
  79. package/src/lib/interactive-opts.ts +0 -13
  80. package/src/lib/migration-files.ts +0 -58
  81. package/src/lib/output.ts +0 -39
  82. package/src/lib/platform-api.ts +0 -209
  83. package/src/lib/proxy/context.ts +0 -94
  84. package/src/lib/proxy/db-proxy.ts +0 -40
  85. package/src/lib/proxy/graph-proxy.ts +0 -64
  86. package/src/lib/proxy/sdk-proxy.ts +0 -58
  87. package/src/lib/read-body.ts +0 -57
  88. package/src/lib/resources.ts +0 -160
  89. package/src/lib/schema-loader.ts +0 -152
  90. package/src/lib/snippets.ts +0 -165
  91. package/src/lib/templates.ts +0 -157
  92. package/src/lib/workspace-context.ts +0 -43
  93. package/src/lib/workspaces.ts +0 -283
  94. package/test/apply-loaders.test.ts +0 -40
  95. package/test/encryption.test.ts +0 -20
  96. package/test/forms.test.ts +0 -25
  97. package/test/http.test.ts +0 -15
  98. package/test/platform-api.test.ts +0 -42
  99. package/test/resources.test.ts +0 -23
  100. package/test/workspace-resolve.test.ts +0 -29
  101. package/tsconfig.json +0 -15
@@ -0,0 +1,753 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { createProduct, getProduct } from '../lib/platform-api.js';
5
+ import { requireWorkspaceContext } from '../lib/workspace-context.js';
6
+ import { printJson } from '../lib/output.js';
7
+ import { validateMigrationE2EBaseline } from './migration-e2e.js';
8
+ import { writeMigrationJson } from '../lib/migration-artifact.js';
9
+ const IGNORED_DIRECTORIES = new Set([
10
+ '.git', '.gradle', '.idea', '.next', '.nuget', '.turbo', '.venv',
11
+ 'bin', 'build', 'coverage', 'dist', 'node_modules', 'obj', 'target', 'vendor',
12
+ ]);
13
+ const TEXT_EXTENSIONS = new Set([
14
+ '.bzl', '.cs', '.csproj', '.env', '.go', '.gradle', '.java', '.js', '.json', '.jsx', '.kt',
15
+ '.graphql', '.groovy', '.md', '.prisma', '.properties', '.proto', '.sh', '.sql', '.tf', '.tfvars', '.toml',
16
+ '.ts', '.tsx', '.xml', '.yaml', '.yml',
17
+ ]);
18
+ function walk(root) {
19
+ const files = [];
20
+ const visit = (directory) => {
21
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
22
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
23
+ continue;
24
+ const fullPath = path.join(directory, entry.name);
25
+ if (entry.isDirectory())
26
+ visit(fullPath);
27
+ else if (entry.isFile())
28
+ files.push(fullPath);
29
+ }
30
+ };
31
+ visit(root);
32
+ return files;
33
+ }
34
+ function skippedSymlinks(root) {
35
+ const skipped = [];
36
+ const visit = (directory) => {
37
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
38
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
39
+ continue;
40
+ const fullPath = path.join(directory, entry.name);
41
+ if (entry.isSymbolicLink())
42
+ skipped.push(relative(root, fullPath));
43
+ else if (entry.isDirectory())
44
+ visit(fullPath);
45
+ }
46
+ };
47
+ visit(root);
48
+ return skipped.sort();
49
+ }
50
+ function gitSubmodules(root) {
51
+ const file = path.join(root, '.gitmodules');
52
+ if (!fs.existsSync(file))
53
+ return [];
54
+ const result = [];
55
+ let current;
56
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
57
+ const section = line.match(/^\s*\[submodule\s+"([^"]+)"\]\s*$/);
58
+ if (section) {
59
+ if (current?.path)
60
+ result.push({ name: current.name, path: current.path, url: current.url });
61
+ current = { name: section[1] };
62
+ continue;
63
+ }
64
+ const property = line.match(/^\s*(path|url)\s*=\s*(.+?)\s*$/);
65
+ if (current && property)
66
+ current[property[1]] = property[2];
67
+ }
68
+ if (current?.path)
69
+ result.push({ name: current.name, path: current.path, url: current.url });
70
+ return result;
71
+ }
72
+ function globPattern(pattern) {
73
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
74
+ .replace(/\*\*/g, '::DOUBLE_STAR::')
75
+ .replace(/\*/g, '[^/]*')
76
+ .replace(/::DOUBLE_STAR::/g, '.*')
77
+ .replace(/\?/g, '[^/]');
78
+ return new RegExp(`^${escaped}$`);
79
+ }
80
+ function looksBinary(file) {
81
+ try {
82
+ const descriptor = fs.openSync(file, 'r');
83
+ const buffer = Buffer.alloc(8_192);
84
+ const length = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
85
+ fs.closeSync(descriptor);
86
+ return buffer.subarray(0, length).includes(0);
87
+ }
88
+ catch {
89
+ return true;
90
+ }
91
+ }
92
+ function applyScanPolicy(root, files, opts) {
93
+ const included = [];
94
+ const exclusions = [];
95
+ const includeRules = opts.include.map(globPattern);
96
+ const excludeRules = opts.exclude.map(globPattern);
97
+ for (const file of files) {
98
+ const rel = relative(root, file);
99
+ let reason;
100
+ if (includeRules.length && !includeRules.some((rule) => rule.test(rel)))
101
+ reason = 'not matched by include policy';
102
+ else if (excludeRules.some((rule) => rule.test(rel)))
103
+ reason = 'matched exclude policy';
104
+ else if (fs.statSync(file).size > opts.maxFileBytes)
105
+ reason = `exceeds max_file_bytes (${opts.maxFileBytes})`;
106
+ else if (looksBinary(file))
107
+ reason = 'binary file';
108
+ if (reason)
109
+ exclusions.push({ file: rel, reason });
110
+ else
111
+ included.push(file);
112
+ }
113
+ return {
114
+ files: included,
115
+ policy: {
116
+ max_file_bytes: opts.maxFileBytes,
117
+ binary_files: 'exclude',
118
+ include_patterns: opts.include,
119
+ exclude_patterns: opts.exclude,
120
+ exclusions,
121
+ },
122
+ };
123
+ }
124
+ function relative(root, file) {
125
+ return path.relative(root, file).split(path.sep).join('/');
126
+ }
127
+ function readText(file) {
128
+ try {
129
+ if (fs.statSync(file).size > 2_000_000)
130
+ return '';
131
+ return fs.readFileSync(file, 'utf8');
132
+ }
133
+ catch {
134
+ return '';
135
+ }
136
+ }
137
+ function slug(value) {
138
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'app';
139
+ }
140
+ function isNotFoundError(error) {
141
+ const candidate = error;
142
+ const status = Number(candidate?.status ?? candidate?.statusCode ?? candidate?.response?.status);
143
+ return status === 404 || /\b404\b|not found|does not exist/i.test(String(candidate?.message ?? ''));
144
+ }
145
+ function detectLanguages(files) {
146
+ const found = new Set();
147
+ for (const file of files) {
148
+ const name = path.basename(file);
149
+ const ext = path.extname(file);
150
+ if (['.ts', '.tsx', '.js', '.jsx'].includes(ext) || name === 'package.json')
151
+ found.add('typescript');
152
+ if (ext === '.go' || name === 'go.mod')
153
+ found.add('go');
154
+ if (ext === '.java' || name === 'pom.xml' || name.startsWith('build.gradle'))
155
+ found.add('java');
156
+ if (ext === '.cs' || ext === '.csproj' || ext === '.sln')
157
+ found.add('dotnet');
158
+ }
159
+ return [...found];
160
+ }
161
+ function detectFrameworks(files, texts) {
162
+ const joined = [...texts.values()].join('\n');
163
+ const frameworks = new Set();
164
+ if (/@nestjs\/|from ['"]@nestjs/.test(joined))
165
+ frameworks.add('nestjs');
166
+ if (/express(?:['"/])|from ['"]express/.test(joined))
167
+ frameworks.add('express');
168
+ if (/org\.springframework|spring-boot/.test(joined))
169
+ frameworks.add('spring');
170
+ if (/Microsoft\.AspNetCore|WebApplication\.CreateBuilder/.test(joined))
171
+ frameworks.add('aspnet');
172
+ if (/github\.com\/gin-gonic\/gin/.test(joined))
173
+ frameworks.add('gin');
174
+ if (/github\.com\/labstack\/echo/.test(joined))
175
+ frameworks.add('echo');
176
+ if (files.some((file) => path.basename(file) === 'next.config.js' || path.basename(file) === 'next.config.ts'))
177
+ frameworks.add('nextjs');
178
+ if (/(?:from ['"]react['"]|["']react["']\s*:)/.test(joined))
179
+ frameworks.add('react');
180
+ if (/(?:from ['"]vue['"]|["']vue["']\s*:)/.test(joined))
181
+ frameworks.add('vue');
182
+ if (/(?:@angular\/core|angular\.json)/.test(joined))
183
+ frameworks.add('angular');
184
+ if (/(?:from ['"]svelte['"]|@sveltejs\/kit|svelte\.config)/.test(joined))
185
+ frameworks.add('svelte');
186
+ return [...frameworks];
187
+ }
188
+ function detectWorkspaceBoundaries(root, files) {
189
+ const relativeFiles = files.map((file) => relative(root, file));
190
+ const rules = [
191
+ ['bazel', (file) => /(?:^|\/)(?:WORKSPACE|WORKSPACE\.bazel|MODULE\.bazel|BUILD|BUILD\.bazel)$/.test(file)],
192
+ ['buck', (file) => /(?:^|\/)(?:BUCK|\.buckconfig)$/.test(file)],
193
+ ['pants', (file) => /(?:^|\/)(?:pants\.toml|pants)$/.test(file)],
194
+ ['nx', (file) => /(?:^|\/)(?:nx\.json|project\.json)$/.test(file)],
195
+ ['turborepo', (file) => /(?:^|\/)turbo\.json$/.test(file)],
196
+ ['maven', (file) => /(?:^|\/)pom\.xml$/.test(file)],
197
+ ['gradle', (file) => /(?:^|\/)(?:settings\.gradle(?:\.kts)?|build\.gradle(?:\.kts)?)$/.test(file)],
198
+ ['dotnet', (file) => /\.(?:sln|slnx|csproj)$/.test(file)],
199
+ ];
200
+ return rules.flatMap(([system, matches]) => {
201
+ const evidence = relativeFiles.filter(matches);
202
+ return evidence.length ? [{ system, evidence, requires_ai_review: true }] : [];
203
+ });
204
+ }
205
+ function detectWorkspaceUnits(root, files, boundaries) {
206
+ const boundaryFiles = boundaries.flatMap((boundary) => boundary.evidence);
207
+ const manifests = [
208
+ [/(?:^|\/)package\.json$/, 'node'],
209
+ [/(?:^|\/)go\.mod$/, 'go'],
210
+ [/(?:^|\/)pom\.xml$/, 'maven'],
211
+ [/(?:^|\/)build\.gradle(?:\.kts)?$/, 'gradle'],
212
+ [/\.(?:csproj|fsproj|vbproj)$/, 'dotnet'],
213
+ [/(?:^|\/)project\.json$/, 'nx'],
214
+ ];
215
+ return files.flatMap((file) => {
216
+ const manifest = relative(root, file);
217
+ const match = manifests.find(([pattern]) => pattern.test(manifest));
218
+ if (!match)
219
+ return [];
220
+ const directory = path.posix.dirname(manifest);
221
+ const nearest = boundaryFiles
222
+ .filter((candidate) => {
223
+ const boundaryDirectory = path.posix.dirname(candidate);
224
+ return boundaryDirectory === '.' || directory === boundaryDirectory || directory.startsWith(`${boundaryDirectory}/`);
225
+ })
226
+ .sort((a, b) => path.posix.dirname(b).length - path.posix.dirname(a).length)[0];
227
+ return [{
228
+ manifest,
229
+ kind: match[1],
230
+ ...(nearest ? { nearest_boundary: nearest } : {}),
231
+ ownership_status: 'contextual_review_required',
232
+ }];
233
+ });
234
+ }
235
+ function detectGeneratedSources(root, files) {
236
+ const headerPattern = /(?:code generated|auto[- ]generated|generated by|do not edit|swagger codegen|openapi generator|protoc)/i;
237
+ return files.flatMap((file) => {
238
+ const rel = relative(root, file);
239
+ const basename = path.basename(file);
240
+ const header = readText(file).slice(0, 2_000);
241
+ const pathEvidence = /(?:^|\/)(?:generated|gen|autogen)(?:\/|$)|\.g\.(?:cs|ts|java)$|\.generated\./i.test(rel);
242
+ const headerMatch = header.match(headerPattern)?.[0];
243
+ if (!pathEvidence && !headerMatch)
244
+ return [];
245
+ return [{
246
+ file: rel,
247
+ evidence: headerMatch ? `header marker: ${headerMatch}` : `generated-looking path/name: ${basename}`,
248
+ confidence: 'low',
249
+ requires_ai_review: true,
250
+ }];
251
+ });
252
+ }
253
+ function environmentFromFile(file) {
254
+ const name = path.basename(file).toLowerCase();
255
+ if (name.includes('production') || name.endsWith('.prod') || name.includes('.prd'))
256
+ return 'prd';
257
+ if (name.includes('staging') || name.includes('.stage'))
258
+ return 'stg';
259
+ if (name.includes('sandbox') || name.includes('.snd'))
260
+ return 'snd';
261
+ if (name.includes('test'))
262
+ return 'test';
263
+ if (name.includes('local') || name === '.env')
264
+ return 'local';
265
+ if (name.includes('development') || name.includes('.dev'))
266
+ return 'dev';
267
+ return 'unknown';
268
+ }
269
+ function detectSecrets(root, files) {
270
+ const secrets = [];
271
+ for (const file of files.filter((candidate) => path.basename(candidate).startsWith('.env'))) {
272
+ const environment = environmentFromFile(file);
273
+ for (const line of readText(file).split(/\r?\n/)) {
274
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
275
+ if (!match)
276
+ continue;
277
+ secrets.push({
278
+ key: match[1],
279
+ source: relative(root, file),
280
+ environment,
281
+ value: '[REDACTED]',
282
+ });
283
+ }
284
+ }
285
+ return secrets;
286
+ }
287
+ function detectSecretReferences(texts) {
288
+ const found = new Map();
289
+ const rules = [
290
+ ['github', /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g],
291
+ ['gitlab', /\$\{?([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL)[A-Z0-9_]*)\}?/g],
292
+ ['jenkins', /\bcredentials\(\s*['"]([^'"]+)['"]\s*\)/g],
293
+ ['kubernetes', /\bsecretKeyRef\s*:\s*(?:\r?\n[ \t]+[^\n]*)*?\r?\n[ \t]+key\s*:\s*['"]?([A-Za-z0-9_.-]+)/g],
294
+ ['kubernetes', /\bremoteRef\s*:\s*(?:\r?\n[ \t]+[^\n]*)*?\r?\n[ \t]+key\s*:\s*['"]?([A-Za-z0-9_./-]+)/g],
295
+ ['spring', /\$\{([A-Za-z_][A-Za-z0-9_.-]*)(?::[^}]*)?\}/g],
296
+ ['dotnet', /(?:GetConnectionString\(\s*|Configuration\s*\[\s*)['"]([^'"]+)['"]/g],
297
+ ['dotnet', /<UserSecretsId>\s*([^<\r\n]+)\s*<\/UserSecretsId>/g],
298
+ ['terraform', /\bvar\.([A-Za-z_][A-Za-z0-9_]*)/g],
299
+ ['aws', /(?:secretsmanager|secret-id|secretId)[/:="'\s]+([A-Za-z0-9_./-]+)/gi],
300
+ ['gcp', /(?:secretmanager|secretVersion|secret-version)[/:="'\s]+([A-Za-z0-9_./-]+)/gi],
301
+ ['azure', /(?:vault\.azure\.net\/secrets\/|secretName\s*[:=]\s*['"])([A-Za-z0-9_.-]+)/gi],
302
+ ['docker', /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-[^}]*)?\}/g],
303
+ ['circleci', /\$\{?([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL)[A-Z0-9_]*)\}?/g],
304
+ ['azure_devops', /\$\(([A-Za-z_][A-Za-z0-9_.-]*(?:TOKEN|SECRET|PASSWORD|KEY|CREDENTIAL)[A-Za-z0-9_.-]*)\)/gi],
305
+ ];
306
+ for (const [source, text] of texts) {
307
+ for (const [system, pattern] of rules) {
308
+ const lower = source.toLowerCase();
309
+ const applicable = system === 'github' ? lower.includes('.github/workflows/') :
310
+ system === 'gitlab' ? lower.endsWith('.gitlab-ci.yml') || lower.endsWith('.gitlab-ci.yaml') :
311
+ system === 'jenkins' ? /jenkinsfile|\.groovy$/.test(lower) :
312
+ system === 'circleci' ? lower.includes('.circleci/') :
313
+ system === 'azure_devops' ? /azure-pipelines.*\.ya?ml$/.test(lower) :
314
+ system === 'spring' ? /application.*\.(?:properties|ya?ml)$|\.java$/.test(lower) :
315
+ system === 'dotnet' ? /\.(?:cs|csproj|fsproj|vbproj|json)$/.test(lower) :
316
+ system === 'terraform' ? /\.tf(?:vars)?$/.test(lower) :
317
+ system === 'docker' ? /docker-compose|compose\.ya?ml$|dockerfile/.test(lower) :
318
+ true;
319
+ if (!applicable)
320
+ continue;
321
+ for (const match of text.matchAll(pattern)) {
322
+ const key = match[1];
323
+ if (!key)
324
+ continue;
325
+ const id = `${system}:${source}:${key}`;
326
+ found.set(id, {
327
+ key,
328
+ source,
329
+ system,
330
+ environment: environmentFromFile(source),
331
+ value: '[NOT_READ]',
332
+ requires_ai_review: true,
333
+ });
334
+ }
335
+ }
336
+ if (/kind\s*:\s*SealedSecret/i.test(text)) {
337
+ const block = text.match(/\bencryptedData\s*:\s*\r?\n((?:[ \t]+[A-Za-z0-9_.-]+\s*:[^\n]*\r?\n?)*)/i)?.[1] ?? '';
338
+ for (const line of block.split(/\r?\n/)) {
339
+ const key = line.match(/^\s+([A-Za-z0-9_.-]+)\s*:/)?.[1];
340
+ if (!key)
341
+ continue;
342
+ found.set(`kubernetes:${source}:${key}`, {
343
+ key, source, system: 'kubernetes', environment: environmentFromFile(source),
344
+ value: '[NOT_READ]', requires_ai_review: true,
345
+ });
346
+ }
347
+ }
348
+ }
349
+ return [...found.values()];
350
+ }
351
+ function detectPotentialSecretExposures(texts) {
352
+ const findings = [];
353
+ const assignment = /\b(password|passwd|token|secret|api[_-]?key|private[_-]?key|connection[_-]?string)\b\s*[:=]\s*["'`]([^"'`\r\n]{8,})["'`]/ig;
354
+ for (const [source, text] of texts) {
355
+ for (const match of text.matchAll(assignment)) {
356
+ const candidate = match[2].trim();
357
+ if (/^(?:\$\{|\$Secret\{|process\.env|env\(|<|REDACTED|example|changeme)/i.test(candidate))
358
+ continue;
359
+ const label = match[1].toLowerCase();
360
+ const kind = label.includes('password') || label === 'passwd' ? 'password' :
361
+ label.includes('token') ? 'token' :
362
+ label.includes('private') ? 'private_key' :
363
+ label.includes('connection') ? 'connection_string' : 'secret';
364
+ findings.push({
365
+ source,
366
+ line: text.slice(0, match.index ?? 0).split(/\r?\n/).length,
367
+ kind,
368
+ fingerprint: crypto.createHash('sha256').update(candidate).digest('hex').slice(0, 16),
369
+ value: '[NOT_RETURNED]',
370
+ confidence: 'low',
371
+ requires_human_security_review: true,
372
+ });
373
+ }
374
+ }
375
+ return findings;
376
+ }
377
+ function normalizeEnvironment(value) {
378
+ const normalized = value.toLowerCase();
379
+ if (['production', 'prod', 'prd'].includes(normalized))
380
+ return 'prd';
381
+ if (['staging', 'stage', 'stg'].includes(normalized))
382
+ return 'stg';
383
+ if (['sandbox', 'snd'].includes(normalized))
384
+ return 'snd';
385
+ if (['development', 'develop', 'dev'].includes(normalized))
386
+ return 'dev';
387
+ if (['testing', 'test'].includes(normalized))
388
+ return 'test';
389
+ if (['local'].includes(normalized))
390
+ return 'local';
391
+ return undefined;
392
+ }
393
+ function detectEnvironments(root, files, texts, secrets) {
394
+ const found = new Set(secrets.map((item) => item.environment).filter((item) => item !== 'unknown'));
395
+ for (const file of files) {
396
+ const rel = relative(root, file).toLowerCase();
397
+ for (const token of rel.split(/[^a-z0-9]+/)) {
398
+ const env = normalizeEnvironment(token);
399
+ if (env)
400
+ found.add(env);
401
+ }
402
+ }
403
+ for (const text of texts.values()) {
404
+ for (const match of text.matchAll(/\b(?:DUCTAPE_ENV|NODE_ENV|ASPNETCORE_ENVIRONMENT|SPRING_PROFILES_ACTIVE|ENVIRONMENT)\s*[:=]\s*["']?([A-Za-z]+)["']?/gi)) {
405
+ const env = normalizeEnvironment(match[1]);
406
+ if (env)
407
+ found.add(env);
408
+ }
409
+ }
410
+ if (!found.size) {
411
+ found.add('snd');
412
+ found.add('prd');
413
+ }
414
+ return [...found];
415
+ }
416
+ function detectApis(texts) {
417
+ const found = new Map();
418
+ const urlPattern = /https?:\/\/([A-Za-z0-9.-]+)(?::\d+)?(\/[A-Za-z0-9_./?={}&%-]*)?/g;
419
+ for (const text of texts.values()) {
420
+ for (const match of text.matchAll(urlPattern)) {
421
+ const host = match[1].toLowerCase();
422
+ if (host === 'localhost' || host.startsWith('127.'))
423
+ continue;
424
+ const record = found.get(host) ?? { occurrences: 0, methods: new Set(), paths: new Set() };
425
+ record.occurrences += 1;
426
+ const context = text.slice(Math.max(0, (match.index ?? 0) - 100), (match.index ?? 0) + match[0].length + 100);
427
+ const method = context.match(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/i)?.[1]?.toUpperCase();
428
+ if (method)
429
+ record.methods.add(method);
430
+ if (match[2])
431
+ record.paths.add(match[2].split('?')[0]);
432
+ found.set(host, record);
433
+ }
434
+ }
435
+ return [...found.entries()]
436
+ .sort((a, b) => b[1].occurrences - a[1].occurrences)
437
+ .map(([host, record]) => ({
438
+ host,
439
+ occurrences: record.occurrences,
440
+ methods: [...record.methods].sort(),
441
+ paths: [...record.paths].sort().slice(0, 50),
442
+ recommended_app_tag: slug(host.replace(/^api\./, '')),
443
+ confidence: 'low',
444
+ requires_ai_review: true,
445
+ }));
446
+ }
447
+ function detectDatabases(texts) {
448
+ const signatures = [
449
+ [/mongoose|mongodb(?:\+srv)?:\/\//i, 'mongodb'],
450
+ [/typeorm|sequelize|postgres(?:ql)?:\/\//i, 'postgresql'],
451
+ [/mysql2?|mysql:\/\//i, 'mysql'],
452
+ [/Microsoft\.EntityFrameworkCore|DbContext/i, 'entity-framework'],
453
+ [/org\.springframework\.data|JpaRepository|Hibernate/i, 'jpa'],
454
+ [/gorm\.io|database\/sql/i, 'go-sql'],
455
+ ];
456
+ const found = [];
457
+ for (const [file, text] of texts) {
458
+ for (const [pattern, kind] of signatures) {
459
+ if (pattern.test(text) && !found.some((item) => item.evidence === file && item.kind === kind)) {
460
+ found.push({ evidence: file, kind, confidence: 'low', requires_ai_review: true });
461
+ }
462
+ }
463
+ }
464
+ return found;
465
+ }
466
+ const FILE_REVIEW_CHECKLIST = [
467
+ 'purpose and ownership',
468
+ 'imports, dependencies, and callers',
469
+ 'public interfaces, types, signatures, DTOs, event schemas, and serialized contracts',
470
+ 'configuration and secret-name references',
471
+ 'database reads, writes, transactions, schemas, and migrations',
472
+ 'internal events, queues, schedules, and background work',
473
+ 'external HTTP/API integrations and authentication',
474
+ 'sessions, actor propagation, authorization, and privacy',
475
+ 'cache, storage, notifications, graph, and vector usage',
476
+ 'timeouts, retries, fallbacks, idempotency, and failure ownership',
477
+ 'tests, invariants, public contracts, and shutdown behavior',
478
+ 'functional behavior: inputs, outputs, validation, domain results, side effects, and externally visible behavior',
479
+ 'frontend behavior where present: routes, render states, forms, accessibility, responsive layout, browser storage, authentication, SSR/hydration, realtime, analytics/privacy, performance, visual, and cross-browser behavior',
480
+ 'happy-path and failure-path operational behavior that must remain equivalent',
481
+ 'follow-up files that must be inspected before deciding',
482
+ ];
483
+ function buildReviewQueue(root, files) {
484
+ return files
485
+ .filter((file) => {
486
+ const basename = path.basename(file);
487
+ return TEXT_EXTENSIONS.has(path.extname(file)) ||
488
+ basename.startsWith('.env') ||
489
+ ['Dockerfile', 'go.mod', 'pom.xml'].includes(basename) ||
490
+ basename.endsWith('.csproj') ||
491
+ basename.startsWith('build.gradle');
492
+ })
493
+ .map((file) => ({
494
+ file: relative(root, file),
495
+ review_status: 'pending',
496
+ inspect_for: FILE_REVIEW_CHECKLIST,
497
+ safety: path.basename(file).startsWith('.env') ? 'names_only' : 'normal',
498
+ }))
499
+ .sort((a, b) => a.file.localeCompare(b.file));
500
+ }
501
+ function buildPlan(source, files, scanPolicy, mode, destination, productTag, productName, databaseTag) {
502
+ const texts = new Map();
503
+ for (const file of files) {
504
+ const basename = path.basename(file);
505
+ if (basename.startsWith('.env')) {
506
+ // Secret files are parsed only by detectSecrets, which extracts names and discards values.
507
+ // Their contents must never influence hints that are returned to MCP.
508
+ texts.set(relative(source, file), '');
509
+ }
510
+ else if (TEXT_EXTENSIONS.has(path.extname(file)) ||
511
+ ['Dockerfile', 'Jenkinsfile'].includes(basename)) {
512
+ texts.set(relative(source, file), readText(file));
513
+ }
514
+ }
515
+ const migrations = files
516
+ .filter((file) => /(?:^|[/\\])(migrations?|flyway|liquibase|alembic)(?:[/\\]|$)/i.test(file))
517
+ .map((file) => relative(source, file));
518
+ const schemas = files
519
+ .filter((file) => /(?:schema\.(?:prisma|sql|json)|liquibase|changelog|dbcontext|entities?)/i.test(file))
520
+ .map((file) => relative(source, file));
521
+ const secrets = detectSecrets(source, files);
522
+ const environments = detectEnvironments(source, files, texts, secrets);
523
+ const apiCalls = detectApis(texts);
524
+ const migrationImports = migrations.map((migration, order) => ({
525
+ source: migration,
526
+ checksum: crypto.createHash('sha256').update(fs.readFileSync(path.join(source, migration))).digest('hex'),
527
+ order: order + 1,
528
+ status: 'manual_review_required',
529
+ reason: 'Ductape migrations must use portable reversible operations; source migrations are never executed or wrapped as raw SQL automatically.',
530
+ }));
531
+ const guidanceDirectory = path.join(destination, 'ductape', 'migration-guidance');
532
+ const planPath = path.join(guidanceDirectory, 'analysis.json');
533
+ const workspaceBoundaries = detectWorkspaceBoundaries(source, files);
534
+ return {
535
+ version: 2,
536
+ artifact_kind: 'ai_review_bootstrap',
537
+ authority: 'advisory_only',
538
+ source,
539
+ mode,
540
+ destination,
541
+ product: { tag: productTag, name: productName },
542
+ target_database: databaseTag,
543
+ languages: detectLanguages(files),
544
+ frameworks: detectFrameworks(files, texts),
545
+ workspace_boundaries: workspaceBoundaries,
546
+ workspace_units: detectWorkspaceUnits(source, files, workspaceBoundaries),
547
+ generated_source_hints: detectGeneratedSources(source, files),
548
+ scan_safety: {
549
+ symlinks_followed: false,
550
+ symlinks_skipped: skippedSymlinks(source),
551
+ git_submodules: gitSubmodules(source),
552
+ },
553
+ scan_policy: scanPolicy,
554
+ environments,
555
+ secrets,
556
+ secret_references: detectSecretReferences(texts),
557
+ potential_secret_exposures: detectPotentialSecretExposures(texts),
558
+ deterministic_hints: {
559
+ databases: detectDatabases(texts),
560
+ third_party_apis: apiCalls,
561
+ },
562
+ existing_migrations: migrations,
563
+ existing_schemas: schemas,
564
+ migration_imports: migrationImports,
565
+ conversion_warnings: [
566
+ 'No schema or component decision was generated. The AI must inspect source context and record evidence before proposing assets.',
567
+ ],
568
+ review_queue: buildReviewQueue(source, files),
569
+ evidence_ledger_template: {
570
+ file: '',
571
+ purpose: '',
572
+ dependencies: [],
573
+ interfaces: [],
574
+ functional_parity_requirements: [],
575
+ operational_parity_requirements: [],
576
+ findings: [{
577
+ kind: '',
578
+ evidence: '',
579
+ location: { start_line: 1, end_line: 1 },
580
+ symbol: { kind: 'module', name: '' },
581
+ classification: 'observed',
582
+ confidence: 'low',
583
+ requires_followup: [],
584
+ }],
585
+ decision: 'pending',
586
+ },
587
+ ai_working_standards: {
588
+ global: [
589
+ 'Before migration inspection, create and pass the original project E2E suite, then bind its files, command, environment, and evidence in an immutable migration-e2e baseline.',
590
+ 'Scanner findings are evidence, not implementation instructions.',
591
+ 'The AI must inspect relevant source files and installed SDK APIs before editing.',
592
+ 'The AI writes application code with normal editing tools; this planner never rewrites source code.',
593
+ 'Do not change or invent public or internal interfaces, types, signatures, DTOs, event schemas, serialized formats, or error contracts unless the user explicitly authorizes that exact change.',
594
+ 'Do not assume functionality from names, patterns, documentation, or scanner hints; trace implementations, callers, tests, configuration, and runtime effects.',
595
+ 'Maintain full functional parity for inputs, outputs, validation, domain results, side effects, external contracts, and user-visible behavior.',
596
+ 'Maintain full operational parity across successful behavior, failures, ordering, concurrency, transactions, retries, idempotency, authorization, observability, and shutdown.',
597
+ 'If existing behavior is unclear, add or run characterization tests and report the uncertainty; never fabricate compatibility.',
598
+ 'Preserve domain logic and public behavior unless the user explicitly authorizes a change.',
599
+ 'Use Ductape Events for internal asynchronous boundaries and Apps/Actions for external APIs.',
600
+ 'Propagate full sessions only for immediate work; use approved actor metadata or system context for durable work.',
601
+ 'Make consumers idempotent and assign one bounded retry owner for each failure.',
602
+ 'Build, test, rescan, reconcile assets, and smoke-test each migrated vertical slice.',
603
+ 'The final migration gate must pass the exact original E2E command against the checksum-verified original suite.',
604
+ ],
605
+ languages: {
606
+ typescript: [
607
+ 'Inspect package exports and types before choosing @ductape/sdk or @ductape/nestjs APIs.',
608
+ 'Preserve dependency-injection boundaries and use canonical NestJS decorators when NestJS is present.',
609
+ 'Keep SDK lifecycle ownership explicit and close workers/connections during shutdown.',
610
+ ],
611
+ go: [
612
+ 'Propagate context.Context, cancellation, and typed errors through Ductape operations.',
613
+ 'Keep worker ownership and graceful shutdown explicit; do not hide goroutines in generated abstractions.',
614
+ ],
615
+ java: [
616
+ 'Preserve Spring dependency injection and transaction boundaries where present.',
617
+ 'Make executor ownership, cancellation, and asynchronous failure behavior explicit.',
618
+ ],
619
+ dotnet: [
620
+ 'Use dependency injection, configuration binding, hosted services, async/await, and CancellationToken.',
621
+ 'Preserve request scope and dispose SDK-owned resources through the host lifecycle.',
622
+ ],
623
+ },
624
+ },
625
+ output_paths: {
626
+ plan: planPath,
627
+ migration_evidence: path.join(guidanceDirectory, 'migration-evidence.json'),
628
+ review_ledger_template: path.join(guidanceDirectory, 'review-ledger.template.json'),
629
+ guide: path.join(guidanceDirectory, 'AI-MIGRATION-GUIDE.md'),
630
+ },
631
+ next_steps: [
632
+ 'Preserve the passing original E2E baseline manifest as the acceptance oracle; never weaken or overwrite it during migration.',
633
+ `Ensure product "${productTag}" exists, then link the destination directory.`,
634
+ 'Review detected environments and create missing product environments in Workbench.',
635
+ 'Import selected local secret keys with the standalone CLI; never pass values through MCP.',
636
+ 'Have the AI review every relevant file in review_queue, follow referenced files, and maintain an evidence ledger.',
637
+ 'Only after contextual review may the AI propose a Ductape schema or component design.',
638
+ 'Have the AI translate reviewed migration intent into reversible Ductape migrations; never execute evidence files.',
639
+ 'Have the AI design one reusable Ductape App per external API and inspect every call site before editing.',
640
+ 'Have the AI migrate one vertical slice, prove interface, functional, and operational parity, build, test, rescan, verify in snd, and report unresolved evidence.',
641
+ ],
642
+ };
643
+ }
644
+ function renderAiGuide(plan) {
645
+ const languageSections = plan.languages.map((language) => `### ${language}\n\n${plan.ai_working_standards.languages[language].map((rule) => `- ${rule}`).join('\n')}`).join('\n\n');
646
+ return `# Ductape AI Migration Guidance
647
+
648
+ This directory contains advisory inspection artifacts. It contains no generated application code,
649
+ no executable migrations, and no authoritative Ductape assets.
650
+
651
+ ## Mandatory boundary
652
+
653
+ ${plan.ai_working_standards.global.map((rule) => `- ${rule}`).join('\n')}
654
+
655
+ ## Detected language standards
656
+
657
+ ${languageSections || 'No supported server language was detected. The AI must inspect the repository before proceeding.'}
658
+
659
+ ## Workflow
660
+
661
+ 1. Before this plan, create missing project-level E2E tests, run them against the original codebase, and record a passing checksum-bound \`migration-e2e\` baseline.
662
+ 2. Use \`analysis.json.review_queue\` as a coverage queue, not as an architectural conclusion.
663
+ 3. Review each relevant file and record purpose, dependencies, observed evidence, uncertainty, and follow-up files.
664
+ 4. Follow callers, callees, wrappers, configuration, tests, and runtime entrypoints until each behavior is understood.
665
+ 5. Classify every ledger finding as observed, inferred, proposed, or confirmed.
666
+ 6. Treat deterministic hints as low-confidence navigation aids and verify them in source.
667
+ 7. Inspect installed SDK exports and types before proposing Ductape code.
668
+ 8. Produce a contextual migration map, including what must remain ordinary application code.
669
+ 9. Explain the design and flag ambiguous or destructive decisions.
670
+ 10. Edit code using normal AI coding tools, one vertical slice at a time.
671
+ 11. Build, test, re-review changed references, reconcile assets, and smoke-test in snd.
672
+ 12. As the final gate, run the exact original E2E command against the unchanged checksum-verified baseline suite, record final evidence, and require strict \`migration-e2e validate\`.
673
+
674
+ Do not claim review coverage until every relevant queue entry is completed or explicitly excluded with a reason.
675
+ `;
676
+ }
677
+ export function writeMigrationGuidance(plan) {
678
+ fs.mkdirSync(path.dirname(plan.output_paths.plan), { recursive: true });
679
+ writeMigrationJson(plan.output_paths.plan, plan, { createOnly: true });
680
+ writeMigrationJson(plan.output_paths.migration_evidence, plan.migration_imports, { createOnly: true });
681
+ writeMigrationJson(plan.output_paths.review_ledger_template, plan.evidence_ledger_template, { createOnly: true });
682
+ fs.writeFileSync(plan.output_paths.guide, renderAiGuide(plan));
683
+ }
684
+ export function inspectMigrationCodebase(opts) {
685
+ const source = path.resolve(opts.source);
686
+ if (!fs.statSync(source).isDirectory())
687
+ throw new Error(`Migration source is not a directory: ${source}`);
688
+ const mode = opts.mode ?? 'new-codebase';
689
+ if (mode !== 'in-place' && mode !== 'new-codebase') {
690
+ throw new Error(`Invalid migration mode "${mode}". Use in-place or new-codebase.`);
691
+ }
692
+ const destination = mode === 'in-place'
693
+ ? source
694
+ : path.resolve(opts.destination ?? `${source}-ductape`);
695
+ if (mode === 'new-codebase' && destination === source) {
696
+ throw new Error('new-codebase mode requires a destination different from the source');
697
+ }
698
+ const productTag = opts.product ?? slug(path.basename(source));
699
+ const productName = opts.name ?? path.basename(source);
700
+ const databaseTag = opts.database ?? `${productTag}-db`;
701
+ const maxFileBytes = opts.maxFileBytes ?? 2_000_000;
702
+ if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes <= 0) {
703
+ throw new Error('maxFileBytes must be a positive integer.');
704
+ }
705
+ const scan = applyScanPolicy(source, walk(source), {
706
+ maxFileBytes,
707
+ include: opts.include ?? [],
708
+ exclude: opts.exclude ?? [],
709
+ });
710
+ return buildPlan(source, scan.files, scan.policy, mode, destination, productTag, productName, databaseTag);
711
+ }
712
+ export async function runMigrateCodebase(opts) {
713
+ const baseline = validateMigrationE2EBaseline(opts.e2eBaseline);
714
+ if (!baseline.ready) {
715
+ throw new Error(`Migration cannot start until the original E2E baseline is valid: ${baseline.blockers.join('; ')}`);
716
+ }
717
+ const plan = inspectMigrationCodebase({
718
+ source: opts.source ?? process.cwd(),
719
+ mode: opts.mode,
720
+ destination: opts.destination,
721
+ product: opts.product,
722
+ name: opts.name,
723
+ database: opts.database,
724
+ maxFileBytes: opts.maxFileBytes,
725
+ include: opts.include,
726
+ exclude: opts.exclude,
727
+ });
728
+ const productTag = plan.product.tag;
729
+ const productName = plan.product.name;
730
+ if (opts.ensureProduct) {
731
+ const context = requireWorkspaceContext({});
732
+ try {
733
+ const existing = await getProduct(context, { tag: productTag });
734
+ if (existing)
735
+ plan.product.exists = true;
736
+ else
737
+ throw Object.assign(new Error(`Product "${productTag}" was not found`), { status: 404 });
738
+ }
739
+ catch (error) {
740
+ if (!isNotFoundError(error))
741
+ throw error;
742
+ await createProduct(context, {
743
+ tag: productTag,
744
+ name: productName,
745
+ description: `Migrated from ${path.basename(plan.source)}`,
746
+ });
747
+ plan.product.created = true;
748
+ }
749
+ }
750
+ if (opts.write)
751
+ writeMigrationGuidance(plan);
752
+ printJson(plan, Boolean(opts.json));
753
+ }