@delegance/claude-autopilot 5.0.7 → 5.2.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/CHANGELOG.md +42 -0
- package/README.md +5 -1
- package/dist/src/adapters/review-engine/parse-output.js +21 -5
- package/dist/src/cli/fix.js +21 -3
- package/dist/src/cli/index.js +130 -2
- package/dist/src/cli/init-migrate.d.ts +35 -0
- package/dist/src/cli/init-migrate.js +299 -0
- package/dist/src/cli/migrate-doctor.d.ts +19 -0
- package/dist/src/cli/migrate-doctor.js +191 -0
- package/dist/src/core/migrate/alias-resolver.d.ts +18 -0
- package/dist/src/core/migrate/alias-resolver.js +150 -0
- package/dist/src/core/migrate/audit-log.d.ts +30 -0
- package/dist/src/core/migrate/audit-log.js +100 -0
- package/dist/src/core/migrate/contract.d.ts +27 -0
- package/dist/src/core/migrate/contract.js +35 -0
- package/dist/src/core/migrate/detector-rules.d.ts +26 -0
- package/dist/src/core/migrate/detector-rules.js +147 -0
- package/dist/src/core/migrate/detector.d.ts +16 -0
- package/dist/src/core/migrate/detector.js +105 -0
- package/dist/src/core/migrate/dispatcher.d.ts +19 -0
- package/dist/src/core/migrate/dispatcher.js +358 -0
- package/dist/src/core/migrate/doctor-checks.d.ts +19 -0
- package/dist/src/core/migrate/doctor-checks.js +304 -0
- package/dist/src/core/migrate/envelope.d.ts +25 -0
- package/dist/src/core/migrate/envelope.js +84 -0
- package/dist/src/core/migrate/executor.d.ts +33 -0
- package/dist/src/core/migrate/executor.js +102 -0
- package/dist/src/core/migrate/handshake.d.ts +17 -0
- package/dist/src/core/migrate/handshake.js +130 -0
- package/dist/src/core/migrate/migrator.d.ts +34 -0
- package/dist/src/core/migrate/migrator.js +302 -0
- package/dist/src/core/migrate/monorepo.d.ts +2 -0
- package/dist/src/core/migrate/monorepo.js +114 -0
- package/dist/src/core/migrate/policy-enforcer.d.ts +28 -0
- package/dist/src/core/migrate/policy-enforcer.js +111 -0
- package/dist/src/core/migrate/result-parser.d.ts +16 -0
- package/dist/src/core/migrate/result-parser.js +152 -0
- package/dist/src/core/migrate/schema-validator.d.ts +11 -0
- package/dist/src/core/migrate/schema-validator.js +103 -0
- package/dist/src/core/migrate/types.d.ts +49 -0
- package/dist/src/core/migrate/types.js +3 -0
- package/package.json +5 -1
- package/presets/aliases.lock.json +20 -0
- package/presets/schemas/migrate.schema.json +134 -0
- package/skills/autopilot/SKILL.md +29 -9
- package/skills/migrate/skill.manifest.json +7 -0
- package/skills/migrate-none/SKILL.md +40 -0
- package/skills/migrate-none/skill.manifest.json +7 -0
- package/skills/migrate-supabase/SKILL.md +126 -0
- package/skills/migrate-supabase/skill.manifest.json +7 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/core/migrate/detector-rules.ts
|
|
2
|
+
//
|
|
3
|
+
// Detection rules for the init flow. Each rule has explicit confidence;
|
|
4
|
+
// detector returns all matches with their confidence so the UI can
|
|
5
|
+
// auto-select (1 high) or prompt (>1 or any non-high).
|
|
6
|
+
export const DETECTION_RULES = [
|
|
7
|
+
{
|
|
8
|
+
name: 'nextjs-supabase',
|
|
9
|
+
stack: 'nextjs-supabase',
|
|
10
|
+
confidence: 'high',
|
|
11
|
+
requireAll: ['data/deltas', '.claude/supabase-envs.json'],
|
|
12
|
+
defaultSkill: 'migrate.supabase@1',
|
|
13
|
+
promptOnSelect: false,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'supabase-cli',
|
|
17
|
+
stack: 'supabase-cli',
|
|
18
|
+
confidence: 'high',
|
|
19
|
+
requireAll: ['supabase/migrations'],
|
|
20
|
+
excludeIf: ['data/deltas'],
|
|
21
|
+
defaultSkill: 'migrate@1',
|
|
22
|
+
defaultCommand: { exec: 'supabase', args: ['migration', 'up'] },
|
|
23
|
+
promptOnSelect: false,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'prisma-migrate',
|
|
27
|
+
stack: 'prisma-migrate',
|
|
28
|
+
confidence: 'high',
|
|
29
|
+
requireAll: ['prisma/schema.prisma', 'prisma/migrations'],
|
|
30
|
+
defaultSkill: 'migrate@1',
|
|
31
|
+
defaultCommand: { exec: 'prisma', args: ['migrate', 'dev'] },
|
|
32
|
+
promptOnSelect: false,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'prisma-push',
|
|
36
|
+
stack: 'prisma-push',
|
|
37
|
+
confidence: 'low',
|
|
38
|
+
requireAll: ['prisma/schema.prisma'],
|
|
39
|
+
excludeIf: ['prisma/migrations'],
|
|
40
|
+
defaultSkill: 'migrate@1',
|
|
41
|
+
promptOnSelect: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'drizzle-migrate',
|
|
45
|
+
stack: 'drizzle-migrate',
|
|
46
|
+
confidence: 'high',
|
|
47
|
+
requireAll: ['drizzle/migrations'],
|
|
48
|
+
requireAny: ['drizzle.config.ts', 'drizzle.config.js'],
|
|
49
|
+
defaultSkill: 'migrate@1',
|
|
50
|
+
defaultCommand: { exec: 'drizzle-kit', args: ['migrate'] },
|
|
51
|
+
promptOnSelect: false,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'drizzle-push',
|
|
55
|
+
stack: 'drizzle-push',
|
|
56
|
+
confidence: 'low',
|
|
57
|
+
requireAll: [],
|
|
58
|
+
requireAny: ['drizzle.config.ts', 'drizzle.config.js'],
|
|
59
|
+
excludeIf: ['drizzle/migrations'],
|
|
60
|
+
defaultSkill: 'migrate@1',
|
|
61
|
+
promptOnSelect: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'rails',
|
|
65
|
+
stack: 'rails',
|
|
66
|
+
confidence: 'high',
|
|
67
|
+
requireAll: ['db/migrate', 'Gemfile'],
|
|
68
|
+
contentMatches: { file: 'Gemfile', pattern: /\brails\b/ },
|
|
69
|
+
defaultSkill: 'migrate@1',
|
|
70
|
+
defaultCommand: { exec: 'rails', args: ['db:migrate'] },
|
|
71
|
+
promptOnSelect: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'golang-migrate',
|
|
75
|
+
stack: 'golang-migrate',
|
|
76
|
+
confidence: 'high',
|
|
77
|
+
requireAll: ['go.mod', 'migrate'],
|
|
78
|
+
defaultSkill: 'migrate@1',
|
|
79
|
+
promptOnSelect: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'flyway',
|
|
83
|
+
stack: 'flyway',
|
|
84
|
+
confidence: 'high',
|
|
85
|
+
requireAll: [],
|
|
86
|
+
requireAny: ['flyway.conf', 'flyway.toml'],
|
|
87
|
+
defaultSkill: 'migrate@1',
|
|
88
|
+
defaultCommand: { exec: 'flyway', args: ['migrate'] },
|
|
89
|
+
promptOnSelect: false,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'dbmate',
|
|
93
|
+
stack: 'dbmate',
|
|
94
|
+
confidence: 'high',
|
|
95
|
+
requireAll: ['dbmate'],
|
|
96
|
+
defaultSkill: 'migrate@1',
|
|
97
|
+
defaultCommand: { exec: 'dbmate', args: ['up'] },
|
|
98
|
+
promptOnSelect: false,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'alembic',
|
|
102
|
+
stack: 'alembic',
|
|
103
|
+
confidence: 'medium',
|
|
104
|
+
requireAll: ['alembic.ini'],
|
|
105
|
+
defaultSkill: 'migrate@1',
|
|
106
|
+
defaultCommand: { exec: 'alembic', args: ['upgrade', 'head'] },
|
|
107
|
+
promptOnSelect: true,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'django',
|
|
111
|
+
stack: 'django',
|
|
112
|
+
confidence: 'medium',
|
|
113
|
+
requireAll: ['manage.py'],
|
|
114
|
+
requireGlob: ['*/migrations/0001_*.py'],
|
|
115
|
+
defaultSkill: 'migrate@1',
|
|
116
|
+
defaultCommand: { exec: 'python', args: ['manage.py', 'migrate'] },
|
|
117
|
+
promptOnSelect: true,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'ecto',
|
|
121
|
+
stack: 'ecto',
|
|
122
|
+
confidence: 'medium',
|
|
123
|
+
requireAll: ['mix.exs', 'priv/repo/migrations'],
|
|
124
|
+
defaultSkill: 'migrate@1',
|
|
125
|
+
defaultCommand: { exec: 'mix', args: ['ecto.migrate'] },
|
|
126
|
+
promptOnSelect: true,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'typeorm',
|
|
130
|
+
stack: 'typeorm',
|
|
131
|
+
confidence: 'medium',
|
|
132
|
+
requireAll: [],
|
|
133
|
+
requireAny: ['ormconfig.json', 'ormconfig.ts', 'ormconfig.js', 'data-source.ts'],
|
|
134
|
+
defaultSkill: 'migrate@1',
|
|
135
|
+
promptOnSelect: true,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'supabase-bare',
|
|
139
|
+
stack: 'supabase-bare',
|
|
140
|
+
confidence: 'low',
|
|
141
|
+
requireAll: ['supabase'],
|
|
142
|
+
excludeIf: ['supabase/migrations', 'data/deltas'],
|
|
143
|
+
defaultSkill: 'migrate@1',
|
|
144
|
+
promptOnSelect: true,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
//# sourceMappingURL=detector-rules.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DetectionRule, type Confidence } from './detector-rules.ts';
|
|
2
|
+
export interface DetectionMatch {
|
|
3
|
+
rule: DetectionRule;
|
|
4
|
+
/** Same as rule.confidence — surfaced for the UI's convenience. */
|
|
5
|
+
confidence: Confidence;
|
|
6
|
+
}
|
|
7
|
+
export interface DetectionOutput {
|
|
8
|
+
matches: DetectionMatch[];
|
|
9
|
+
/** True when we have a single high-confidence match the caller can
|
|
10
|
+
* auto-select. False when caller should prompt the user. */
|
|
11
|
+
autoSelect: boolean;
|
|
12
|
+
/** True when caller should prompt the user (>1 match, or any non-high). */
|
|
13
|
+
prompt: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function detect(projectRoot: string): DetectionOutput;
|
|
16
|
+
//# sourceMappingURL=detector.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/core/migrate/detector.ts
|
|
2
|
+
//
|
|
3
|
+
// Runs detection rules against a project root. Returns ALL matching
|
|
4
|
+
// rules with their confidence. The init flow uses this:
|
|
5
|
+
// - 1 high-confidence match → auto-select, write stack.md
|
|
6
|
+
// - >1 match OR any non-high → prompt user
|
|
7
|
+
// - 0 matches → fail closed (require --skip-migrate)
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { DETECTION_RULES } from "./detector-rules.js";
|
|
11
|
+
function entryExists(projectRoot, rel) {
|
|
12
|
+
try {
|
|
13
|
+
fs.statSync(path.join(projectRoot, rel));
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function fileMatches(projectRoot, rel, pattern) {
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(path.join(projectRoot, rel), 'utf8');
|
|
23
|
+
return pattern.test(content);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function matchesGlob(projectRoot, glob) {
|
|
30
|
+
// Handle simple patterns: '*/migrations/0001_*.py'
|
|
31
|
+
// We do depth-2 directory walk for simplicity.
|
|
32
|
+
const parts = glob.split('/');
|
|
33
|
+
if (parts.length < 2)
|
|
34
|
+
return false;
|
|
35
|
+
const firstStar = parts[0] === '*';
|
|
36
|
+
if (!firstStar) {
|
|
37
|
+
// No leading wildcard; fall back to direct existence
|
|
38
|
+
return entryExists(projectRoot, glob);
|
|
39
|
+
}
|
|
40
|
+
// Depth-1 dirs under projectRoot, then check the rest
|
|
41
|
+
let dirs = [];
|
|
42
|
+
try {
|
|
43
|
+
dirs = fs.readdirSync(projectRoot, { withFileTypes: true })
|
|
44
|
+
.filter(d => d.isDirectory())
|
|
45
|
+
.map(d => d.name);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const remaining = parts.slice(1);
|
|
51
|
+
for (const dir of dirs) {
|
|
52
|
+
const subPath = path.join(projectRoot, dir, ...remaining.slice(0, -1));
|
|
53
|
+
const lastPart = remaining[remaining.length - 1];
|
|
54
|
+
// last part may be a glob like 0001_*.py
|
|
55
|
+
let entries = [];
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(subPath);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const lastRegex = new RegExp('^' + lastPart.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
63
|
+
if (entries.some(e => lastRegex.test(e)))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
function ruleApplies(rule, projectRoot) {
|
|
69
|
+
for (const r of rule.requireAll) {
|
|
70
|
+
if (!entryExists(projectRoot, r))
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (rule.requireAny && rule.requireAny.length > 0) {
|
|
74
|
+
if (!rule.requireAny.some(r => entryExists(projectRoot, r)))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (rule.requireGlob && rule.requireGlob.length > 0) {
|
|
78
|
+
if (!rule.requireGlob.every(g => matchesGlob(projectRoot, g)))
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (rule.contentMatches) {
|
|
82
|
+
if (!fileMatches(projectRoot, rule.contentMatches.file, rule.contentMatches.pattern))
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (rule.excludeIf) {
|
|
86
|
+
for (const ex of rule.excludeIf) {
|
|
87
|
+
if (entryExists(projectRoot, ex))
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
export function detect(projectRoot) {
|
|
94
|
+
const matches = [];
|
|
95
|
+
for (const rule of DETECTION_RULES) {
|
|
96
|
+
if (ruleApplies(rule, projectRoot)) {
|
|
97
|
+
matches.push({ rule, confidence: rule.confidence });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const highMatches = matches.filter(m => m.confidence === 'high');
|
|
101
|
+
const autoSelect = matches.length === 1 && highMatches.length === 1;
|
|
102
|
+
const prompt = matches.length > 0 && !autoSelect;
|
|
103
|
+
return { matches, autoSelect, prompt };
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=detector.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResultArtifact } from './types.ts';
|
|
2
|
+
export interface DispatchOptions {
|
|
3
|
+
repoRoot: string;
|
|
4
|
+
env: string;
|
|
5
|
+
yesFlag: boolean;
|
|
6
|
+
nonInteractive: boolean;
|
|
7
|
+
/** Runtime version, normally from package.json. */
|
|
8
|
+
currentRuntimeVersion: string;
|
|
9
|
+
/** Override env_file lookup (mainly for tests) */
|
|
10
|
+
envOverride?: Record<string, string>;
|
|
11
|
+
/** Optional changedFiles for envelope (passes through verbatim) */
|
|
12
|
+
changedFiles?: string[];
|
|
13
|
+
/** dryRun pass-through to the envelope */
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
/** projectId for monorepo invocations */
|
|
16
|
+
projectId?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function dispatch(opts: DispatchOptions): Promise<ResultArtifact>;
|
|
19
|
+
//# sourceMappingURL=dispatcher.d.ts.map
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// src/core/migrate/dispatcher.ts
|
|
2
|
+
//
|
|
3
|
+
// Orchestrates the full migrate flow:
|
|
4
|
+
// 1. Read .autopilot/stack.md, validate schema
|
|
5
|
+
// 2. Resolve migrate.skill via alias map (path-escape protected)
|
|
6
|
+
// 3. Skill manifest handshake (runtime range + API version)
|
|
7
|
+
// 4. Build invocation envelope (UUID, nonce, git refs)
|
|
8
|
+
// 5. Enforce policy (4-flag CI prod gate, clean git, etc.)
|
|
9
|
+
// 6. Execute the env command via spawn(shell:false), capturing the
|
|
10
|
+
// result artifact (file or nonce-bound stdout fallback)
|
|
11
|
+
// 7. Parse the result artifact, validate identity
|
|
12
|
+
// 8. Append audit log entry (seq + prev_hash)
|
|
13
|
+
//
|
|
14
|
+
// Fails closed at every step. Always emits an audit entry, even on
|
|
15
|
+
// failure, so operators can see what was attempted.
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import * as yaml from 'js-yaml';
|
|
21
|
+
import { validateStackMd } from "./schema-validator.js";
|
|
22
|
+
import { resolveSkill } from "./alias-resolver.js";
|
|
23
|
+
import { performHandshake } from "./handshake.js";
|
|
24
|
+
import { buildEnvelope } from "./envelope.js";
|
|
25
|
+
import { enforcePolicy } from "./policy-enforcer.js";
|
|
26
|
+
import { executeCommand } from "./executor.js";
|
|
27
|
+
import { parseResult } from "./result-parser.js";
|
|
28
|
+
import { appendAuditEvent } from "./audit-log.js";
|
|
29
|
+
import { ENVELOPE_CONTRACT_VERSION, RESULT_TEMPDIR_MODE } from "./contract.js";
|
|
30
|
+
const DEFAULT_POLICY = {
|
|
31
|
+
allow_prod_in_ci: false,
|
|
32
|
+
require_clean_git: true,
|
|
33
|
+
require_manual_approval: true,
|
|
34
|
+
require_dry_run_first: false,
|
|
35
|
+
};
|
|
36
|
+
function readStackMd(repoRoot) {
|
|
37
|
+
const stackPath = path.join(repoRoot, '.autopilot', 'stack.md');
|
|
38
|
+
if (!fs.existsSync(stackPath))
|
|
39
|
+
return { ok: false, reason: 'stack.md not found' };
|
|
40
|
+
const raw = fs.readFileSync(stackPath, 'utf8');
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = yaml.load(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, reason: 'stack.md YAML parse failed' };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, raw, parsed: parsed };
|
|
49
|
+
}
|
|
50
|
+
function loadEnvFile(envFilePath, repoRoot) {
|
|
51
|
+
const abs = path.resolve(repoRoot, envFilePath);
|
|
52
|
+
if (!fs.existsSync(abs))
|
|
53
|
+
return {};
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const rawLine of fs.readFileSync(abs, 'utf8').split('\n')) {
|
|
56
|
+
const line = rawLine.trim();
|
|
57
|
+
if (!line || line.startsWith('#'))
|
|
58
|
+
continue;
|
|
59
|
+
// Accept lowercase, uppercase, and mixed-case identifiers — many real-world
|
|
60
|
+
// env files (e.g. local docker postgres) use lowercase like `database_url`.
|
|
61
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
62
|
+
if (!m)
|
|
63
|
+
continue;
|
|
64
|
+
let value = m[2];
|
|
65
|
+
// Strip balanced surrounding quotes (single or double) to mirror typical
|
|
66
|
+
// dotenv parsing behavior.
|
|
67
|
+
if (value.length >= 2 &&
|
|
68
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
69
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
70
|
+
value = value.slice(1, -1);
|
|
71
|
+
}
|
|
72
|
+
out[m[1]] = value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function hashEnvelope(env) {
|
|
77
|
+
return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(env)).digest('hex');
|
|
78
|
+
}
|
|
79
|
+
async function emitAuditFailure(repoRoot, opts, reasonCode, partial) {
|
|
80
|
+
try {
|
|
81
|
+
await appendAuditEvent(path.join(repoRoot, '.autopilot', 'audit.log'), {
|
|
82
|
+
invocationId: partial.invocationId ?? 'pre-envelope',
|
|
83
|
+
event: 'dispatch',
|
|
84
|
+
requested_skill: partial.requested ?? 'unknown',
|
|
85
|
+
resolved_skill: partial.resolved ?? 'unknown',
|
|
86
|
+
skill_path: partial.skillPath ?? '',
|
|
87
|
+
envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
|
|
88
|
+
skill_runtime_api_version: partial.apiVersion ?? 'unknown',
|
|
89
|
+
envelope_hash: partial.envelopeHash ?? '',
|
|
90
|
+
policy_decisions: partial.decisions ?? [],
|
|
91
|
+
mode: opts.dryRun ? 'dry-run' : 'apply',
|
|
92
|
+
actor: process.env.USER ?? 'unknown',
|
|
93
|
+
ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
|
|
94
|
+
ci_run_id: process.env.GITHUB_RUN_ID ?? null,
|
|
95
|
+
result_status: `error:${reasonCode}`,
|
|
96
|
+
duration_ms: partial.durationMs ?? 0,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// last-ditch: don't let audit-log failure mask the real error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function synthErr(reasonCode, invocationId = 'pre-envelope', nonce = '') {
|
|
104
|
+
return {
|
|
105
|
+
contractVersion: ENVELOPE_CONTRACT_VERSION,
|
|
106
|
+
skillId: 'unknown',
|
|
107
|
+
invocationId,
|
|
108
|
+
nonce,
|
|
109
|
+
status: 'error',
|
|
110
|
+
reasonCode,
|
|
111
|
+
appliedMigrations: [],
|
|
112
|
+
destructiveDetected: false,
|
|
113
|
+
sideEffectsPerformed: ['no-side-effects'],
|
|
114
|
+
nextActions: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function dispatch(opts) {
|
|
118
|
+
const t0 = Date.now();
|
|
119
|
+
// 1. Read + validate stack.md
|
|
120
|
+
const stackResult = readStackMd(opts.repoRoot);
|
|
121
|
+
if (!stackResult.ok) {
|
|
122
|
+
await emitAuditFailure(opts.repoRoot, opts, 'invalid-stack-config', {});
|
|
123
|
+
return synthErr('invalid-stack-config');
|
|
124
|
+
}
|
|
125
|
+
const validation = validateStackMd(stackResult.raw);
|
|
126
|
+
if (!validation.valid) {
|
|
127
|
+
const msg = validation.errors.map(e => e.message).join('; ');
|
|
128
|
+
// Map known validator errors to a more specific reason code where possible
|
|
129
|
+
const reasonCode = /stableSkillId|skillId-not-in-registry/.test(msg)
|
|
130
|
+
? 'stable-id-unknown'
|
|
131
|
+
: 'invalid-stack-config';
|
|
132
|
+
await emitAuditFailure(opts.repoRoot, opts, reasonCode, {});
|
|
133
|
+
return synthErr(reasonCode);
|
|
134
|
+
}
|
|
135
|
+
const requestedSkill = stackResult.parsed.migrate.skill;
|
|
136
|
+
// 2. Resolve alias
|
|
137
|
+
const resolved = resolveSkill(requestedSkill, { repoRoot: opts.repoRoot });
|
|
138
|
+
if (!resolved.ok) {
|
|
139
|
+
await emitAuditFailure(opts.repoRoot, opts, resolved.reasonCode, {
|
|
140
|
+
requested: requestedSkill,
|
|
141
|
+
});
|
|
142
|
+
return synthErr(resolved.reasonCode);
|
|
143
|
+
}
|
|
144
|
+
// 3. Handshake
|
|
145
|
+
const handshake = performHandshake({
|
|
146
|
+
skillPath: resolved.skillPath,
|
|
147
|
+
runtimeVersion: opts.currentRuntimeVersion,
|
|
148
|
+
envelopeContractVersion: ENVELOPE_CONTRACT_VERSION,
|
|
149
|
+
});
|
|
150
|
+
if (!handshake.ok) {
|
|
151
|
+
await emitAuditFailure(opts.repoRoot, opts, handshake.reasonCode, {
|
|
152
|
+
requested: requestedSkill,
|
|
153
|
+
resolved: resolved.stableId,
|
|
154
|
+
skillPath: resolved.skillPath,
|
|
155
|
+
});
|
|
156
|
+
return synthErr(handshake.reasonCode);
|
|
157
|
+
}
|
|
158
|
+
// 4. Envelope
|
|
159
|
+
let envelope;
|
|
160
|
+
try {
|
|
161
|
+
envelope = buildEnvelope({
|
|
162
|
+
changedFiles: opts.changedFiles ?? [],
|
|
163
|
+
env: opts.env,
|
|
164
|
+
repoRoot: opts.repoRoot,
|
|
165
|
+
dryRun: opts.dryRun ?? false,
|
|
166
|
+
projectId: opts.projectId,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
await emitAuditFailure(opts.repoRoot, opts, 'envelope-build-failed', {
|
|
171
|
+
requested: requestedSkill,
|
|
172
|
+
resolved: resolved.stableId,
|
|
173
|
+
skillPath: resolved.skillPath,
|
|
174
|
+
apiVersion: handshake.manifest.skill_runtime_api_version,
|
|
175
|
+
});
|
|
176
|
+
return synthErr('envelope-build-failed');
|
|
177
|
+
}
|
|
178
|
+
const envelopeHash = hashEnvelope(envelope);
|
|
179
|
+
// 5. Policy
|
|
180
|
+
const policy = { ...DEFAULT_POLICY, ...(stackResult.parsed.migrate.policy ?? {}) };
|
|
181
|
+
const enforced = enforcePolicy({
|
|
182
|
+
policy,
|
|
183
|
+
env: opts.env,
|
|
184
|
+
repoRoot: opts.repoRoot,
|
|
185
|
+
ci: envelope.ci,
|
|
186
|
+
yesFlag: opts.yesFlag,
|
|
187
|
+
nonInteractive: opts.nonInteractive,
|
|
188
|
+
gitHead: envelope.gitHead,
|
|
189
|
+
});
|
|
190
|
+
if (!enforced.ok) {
|
|
191
|
+
await emitAuditFailure(opts.repoRoot, opts, enforced.reasonCode, {
|
|
192
|
+
invocationId: envelope.invocationId,
|
|
193
|
+
requested: requestedSkill,
|
|
194
|
+
resolved: resolved.stableId,
|
|
195
|
+
skillPath: resolved.skillPath,
|
|
196
|
+
apiVersion: handshake.manifest.skill_runtime_api_version,
|
|
197
|
+
decisions: enforced.decisions,
|
|
198
|
+
envelopeHash,
|
|
199
|
+
});
|
|
200
|
+
return synthErr(enforced.reasonCode, envelope.invocationId, envelope.nonce);
|
|
201
|
+
}
|
|
202
|
+
// 6. Execute
|
|
203
|
+
//
|
|
204
|
+
// Different skills source their command shape differently:
|
|
205
|
+
// - migrate@1 — thin runner, requires envs.<env>.command
|
|
206
|
+
// - migrate.supabase@1 — rich runner; we hand the script the envelope
|
|
207
|
+
// via env vars and let it discover settings from
|
|
208
|
+
// stack.md's `migrate.supabase` block + envs_file.
|
|
209
|
+
// - none@1 — no-op skill; we synthesize a `skipped` result
|
|
210
|
+
// without spawning anything.
|
|
211
|
+
let envSpec;
|
|
212
|
+
if (resolved.stableId === 'none@1') {
|
|
213
|
+
// No-op skill — return synthetic skipped result without spawning anything.
|
|
214
|
+
// Still emit audit entry so the dispatch is observable.
|
|
215
|
+
const skipResult = {
|
|
216
|
+
contractVersion: ENVELOPE_CONTRACT_VERSION,
|
|
217
|
+
skillId: 'none@1',
|
|
218
|
+
invocationId: envelope.invocationId,
|
|
219
|
+
nonce: envelope.nonce,
|
|
220
|
+
status: 'skipped',
|
|
221
|
+
reasonCode: 'migration-disabled',
|
|
222
|
+
appliedMigrations: [],
|
|
223
|
+
destructiveDetected: false,
|
|
224
|
+
sideEffectsPerformed: ['no-side-effects'],
|
|
225
|
+
nextActions: [],
|
|
226
|
+
};
|
|
227
|
+
await appendAuditEvent(path.join(opts.repoRoot, '.autopilot', 'audit.log'), {
|
|
228
|
+
invocationId: envelope.invocationId,
|
|
229
|
+
event: 'dispatch',
|
|
230
|
+
requested_skill: requestedSkill,
|
|
231
|
+
resolved_skill: resolved.stableId,
|
|
232
|
+
skill_path: resolved.skillPath,
|
|
233
|
+
envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
|
|
234
|
+
skill_runtime_api_version: handshake.manifest.skill_runtime_api_version,
|
|
235
|
+
envelope_hash: envelopeHash,
|
|
236
|
+
policy_decisions: enforced.decisions,
|
|
237
|
+
mode: opts.dryRun ? 'dry-run' : 'apply',
|
|
238
|
+
actor: process.env.USER ?? 'unknown',
|
|
239
|
+
ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
|
|
240
|
+
ci_run_id: process.env.GITHUB_RUN_ID ?? null,
|
|
241
|
+
result_status: 'skipped',
|
|
242
|
+
duration_ms: Date.now() - t0,
|
|
243
|
+
});
|
|
244
|
+
return skipResult;
|
|
245
|
+
}
|
|
246
|
+
if (resolved.stableId === 'migrate.supabase@1') {
|
|
247
|
+
// Rich Supabase runner manages env discovery via the supabase block +
|
|
248
|
+
// envs_file. The script's envelope shim activates when AUTOPILOT_ENVELOPE
|
|
249
|
+
// is set, regardless of CLI args.
|
|
250
|
+
//
|
|
251
|
+
// We still honor envs.<env>.command when explicitly provided (tests +
|
|
252
|
+
// fixtures rely on it as an escape hatch). When omitted, fall back to
|
|
253
|
+
// invoking the packaged runner.
|
|
254
|
+
const supabaseBlock = stackResult.parsed.migrate.supabase;
|
|
255
|
+
if (!supabaseBlock) {
|
|
256
|
+
await emitAuditFailure(opts.repoRoot, opts, 'invalid-stack-config', {
|
|
257
|
+
invocationId: envelope.invocationId,
|
|
258
|
+
requested: requestedSkill,
|
|
259
|
+
resolved: resolved.stableId,
|
|
260
|
+
skillPath: resolved.skillPath,
|
|
261
|
+
apiVersion: handshake.manifest.skill_runtime_api_version,
|
|
262
|
+
decisions: enforced.decisions,
|
|
263
|
+
envelopeHash,
|
|
264
|
+
});
|
|
265
|
+
return synthErr('invalid-stack-config', envelope.invocationId, envelope.nonce);
|
|
266
|
+
}
|
|
267
|
+
envSpec = stackResult.parsed.migrate.envs?.[opts.env] ?? {
|
|
268
|
+
command: { exec: 'npx', args: ['tsx', 'scripts/supabase/migrate.ts'] },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
envSpec = stackResult.parsed.migrate.envs?.[opts.env];
|
|
273
|
+
}
|
|
274
|
+
if (!envSpec) {
|
|
275
|
+
await emitAuditFailure(opts.repoRoot, opts, 'env-not-configured', {
|
|
276
|
+
invocationId: envelope.invocationId,
|
|
277
|
+
requested: requestedSkill,
|
|
278
|
+
resolved: resolved.stableId,
|
|
279
|
+
skillPath: resolved.skillPath,
|
|
280
|
+
apiVersion: handshake.manifest.skill_runtime_api_version,
|
|
281
|
+
decisions: enforced.decisions,
|
|
282
|
+
envelopeHash,
|
|
283
|
+
});
|
|
284
|
+
return synthErr('env-not-configured', envelope.invocationId, envelope.nonce);
|
|
285
|
+
}
|
|
286
|
+
// Set up per-invocation result file with strict permissions
|
|
287
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'autopilot-result-'));
|
|
288
|
+
fs.chmodSync(tmpRoot, RESULT_TEMPDIR_MODE);
|
|
289
|
+
const resultPath = path.join(tmpRoot, `${envelope.invocationId}.json`);
|
|
290
|
+
// Pre-create result file with O_CREAT|O_EXCL|O_WRONLY, mode 0o600, no symlink follow.
|
|
291
|
+
// The skill will then open the existing file for writing — TOCTOU window closed
|
|
292
|
+
// against a malicious pre-placed file or symlink.
|
|
293
|
+
const fd = fs.openSync(resultPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
294
|
+
fs.closeSync(fd);
|
|
295
|
+
const childEnv = {
|
|
296
|
+
...(envSpec.env_file ? loadEnvFile(envSpec.env_file, opts.repoRoot) : {}),
|
|
297
|
+
...(opts.envOverride ?? {}),
|
|
298
|
+
AUTOPILOT_ENVELOPE: JSON.stringify(envelope),
|
|
299
|
+
AUTOPILOT_RESULT_PATH: resultPath,
|
|
300
|
+
};
|
|
301
|
+
let execResult;
|
|
302
|
+
try {
|
|
303
|
+
execResult = await executeCommand(envSpec.command, {
|
|
304
|
+
cwd: opts.repoRoot,
|
|
305
|
+
env: childEnv,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
await emitAuditFailure(opts.repoRoot, opts, 'execute-threw', {
|
|
310
|
+
invocationId: envelope.invocationId,
|
|
311
|
+
requested: requestedSkill,
|
|
312
|
+
resolved: resolved.stableId,
|
|
313
|
+
skillPath: resolved.skillPath,
|
|
314
|
+
apiVersion: handshake.manifest.skill_runtime_api_version,
|
|
315
|
+
decisions: enforced.decisions,
|
|
316
|
+
envelopeHash,
|
|
317
|
+
});
|
|
318
|
+
try {
|
|
319
|
+
fs.rmSync(tmpRoot, { recursive: true });
|
|
320
|
+
}
|
|
321
|
+
catch { /* */ }
|
|
322
|
+
return synthErr('execute-threw', envelope.invocationId, envelope.nonce);
|
|
323
|
+
}
|
|
324
|
+
// 7. Parse result artifact
|
|
325
|
+
const allowStdoutFallback = handshake.manifest.stdoutFallback === true;
|
|
326
|
+
const result = parseResult({
|
|
327
|
+
filePath: resultPath,
|
|
328
|
+
stdout: execResult.stdout,
|
|
329
|
+
expected: { invocationId: envelope.invocationId, nonce: envelope.nonce },
|
|
330
|
+
allowStdoutFallback,
|
|
331
|
+
});
|
|
332
|
+
// Cleanup temp dir
|
|
333
|
+
try {
|
|
334
|
+
fs.rmSync(tmpRoot, { recursive: true });
|
|
335
|
+
}
|
|
336
|
+
catch { /* */ }
|
|
337
|
+
// 8. Audit log entry (always emitted on the success path)
|
|
338
|
+
const durationMs = Date.now() - t0;
|
|
339
|
+
await appendAuditEvent(path.join(opts.repoRoot, '.autopilot', 'audit.log'), {
|
|
340
|
+
invocationId: envelope.invocationId,
|
|
341
|
+
event: 'dispatch',
|
|
342
|
+
requested_skill: requestedSkill,
|
|
343
|
+
resolved_skill: resolved.stableId,
|
|
344
|
+
skill_path: resolved.skillPath,
|
|
345
|
+
envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
|
|
346
|
+
skill_runtime_api_version: handshake.manifest.skill_runtime_api_version,
|
|
347
|
+
envelope_hash: envelopeHash,
|
|
348
|
+
policy_decisions: enforced.decisions,
|
|
349
|
+
mode: opts.dryRun ? 'dry-run' : 'apply',
|
|
350
|
+
actor: process.env.USER ?? 'unknown',
|
|
351
|
+
ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
|
|
352
|
+
ci_run_id: process.env.GITHUB_RUN_ID ?? null,
|
|
353
|
+
result_status: result.status,
|
|
354
|
+
duration_ms: durationMs,
|
|
355
|
+
});
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CheckResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
message?: string;
|
|
4
|
+
fixHint?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface NamedCheckResult {
|
|
7
|
+
name: string;
|
|
8
|
+
result: CheckResult;
|
|
9
|
+
}
|
|
10
|
+
export declare function stackMdExists(repoRoot: string): CheckResult;
|
|
11
|
+
export declare function schemaValidates(repoRoot: string): CheckResult;
|
|
12
|
+
export declare function skillResolves(repoRoot: string): CheckResult;
|
|
13
|
+
export declare function perEnvCommandsExplicit(repoRoot: string): CheckResult;
|
|
14
|
+
export declare function policyFieldsValid(repoRoot: string): CheckResult;
|
|
15
|
+
export declare function projectRootHasToolchain(repoRoot: string): CheckResult;
|
|
16
|
+
export declare function deprecatedKeysAbsent(repoRoot: string): CheckResult;
|
|
17
|
+
export declare function envFileSafety(repoRoot: string): CheckResult;
|
|
18
|
+
export declare function runAllChecks(repoRoot: string): NamedCheckResult[];
|
|
19
|
+
//# sourceMappingURL=doctor-checks.d.ts.map
|