@elmundi/ship-cli 0.8.1 → 0.12.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/README.md +651 -25
- package/bin/shipctl.mjs +168 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +422 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +742 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +159 -24
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +562 -0
- package/lib/commands/lanes.mjs +527 -0
- package/lib/commands/manifest-catalog.mjs +106 -42
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +14 -48
- package/lib/commands/run.mjs +857 -0
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +824 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/trigger.mjs +196 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +223 -0
- package/lib/config/schema.mjs +901 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- package/bin/ship.mjs +0 -68
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readConfig, findShipRoot } from "../config/io.mjs";
|
|
4
|
+
import { allChecks, runChecks, summarize } from "../verify/registry.mjs";
|
|
5
|
+
|
|
6
|
+
const SEVERITY_ORDER = { info: 0, warn: 1, error: 2 };
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const out = {
|
|
10
|
+
cwd: null,
|
|
11
|
+
check: /** @type {string[]} */ ([]),
|
|
12
|
+
json: false,
|
|
13
|
+
noNetwork: false,
|
|
14
|
+
severity: "info",
|
|
15
|
+
help: false,
|
|
16
|
+
};
|
|
17
|
+
const copy = [...argv];
|
|
18
|
+
while (copy.length) {
|
|
19
|
+
const a = copy.shift();
|
|
20
|
+
if (a === "--help" || a === "-h") { out.help = true; continue; }
|
|
21
|
+
if (a === "--json") { out.json = true; continue; }
|
|
22
|
+
if (a === "--no-network") { out.noNetwork = true; continue; }
|
|
23
|
+
if (a === "--cwd" && copy.length) { out.cwd = copy.shift(); continue; }
|
|
24
|
+
if (a && a.startsWith("--cwd=")) { out.cwd = a.slice("--cwd=".length); continue; }
|
|
25
|
+
if (a === "--check" && copy.length) {
|
|
26
|
+
for (const s of String(copy.shift()).split(",")) {
|
|
27
|
+
const id = s.trim();
|
|
28
|
+
if (id) out.check.push(id);
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (a && a.startsWith("--check=")) {
|
|
33
|
+
for (const s of a.slice("--check=".length).split(",")) {
|
|
34
|
+
const id = s.trim();
|
|
35
|
+
if (id) out.check.push(id);
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (a === "--severity" && copy.length) { out.severity = copy.shift(); continue; }
|
|
40
|
+
if (a && a.startsWith("--severity=")) { out.severity = a.slice("--severity=".length); continue; }
|
|
41
|
+
// Silently ignore unknown flags so globals (--base-url, --json) don't blow up.
|
|
42
|
+
}
|
|
43
|
+
if (!["info", "warn", "error"].includes(out.severity)) {
|
|
44
|
+
throw new Error(`verify: unknown --severity '${out.severity}'. Expected: info|warn|error`);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printHelp() {
|
|
50
|
+
console.log(`shipctl verify — post-adoption liveness check for a Ship repo.
|
|
51
|
+
|
|
52
|
+
USAGE
|
|
53
|
+
shipctl verify [--cwd DIR] [--check <id,...>] [--no-network]
|
|
54
|
+
[--severity info|warn|error] [--json]
|
|
55
|
+
|
|
56
|
+
OPTIONS
|
|
57
|
+
--cwd DIR Target repo root (defaults to cwd / nearest .ship/).
|
|
58
|
+
--check <id,...> Run only the listed check ids (csv or repeated).
|
|
59
|
+
--no-network Skip checks in the 'network' category.
|
|
60
|
+
--severity <level> Filter displayed rows:
|
|
61
|
+
info (default) — show all checks (pass/warn/fail/skip)
|
|
62
|
+
warn — show warn + fail only
|
|
63
|
+
error — show fail only
|
|
64
|
+
--json Machine-readable output: {checks:[...], summary:{...}}.
|
|
65
|
+
|
|
66
|
+
EXIT CODE
|
|
67
|
+
0 when no checks returned 'fail' (warnings do not fail).
|
|
68
|
+
1 when at least one check failed.
|
|
69
|
+
|
|
70
|
+
AVAILABLE CHECKS
|
|
71
|
+
${allChecks().map((c) => ` ${c.id.padEnd(22)} ${c.description}`).join("\n")}
|
|
72
|
+
`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function loadConfig(cwd) {
|
|
76
|
+
try {
|
|
77
|
+
const { config } = readConfig(cwd);
|
|
78
|
+
return config;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadInventory(cwd) {
|
|
85
|
+
const root = findShipRoot(cwd) || cwd;
|
|
86
|
+
const invPath = path.join(root, ".ship", "inventory.json");
|
|
87
|
+
if (!fs.existsSync(invPath)) return null;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(fs.readFileSync(invPath, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pickByStatus(rows, severity) {
|
|
96
|
+
if (severity === "info") return rows;
|
|
97
|
+
if (severity === "warn") return rows.filter((r) => r.status === "warn" || r.status === "fail");
|
|
98
|
+
return rows.filter((r) => r.status === "fail");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function badge(status) {
|
|
102
|
+
return `[${status}]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pad(s, n) {
|
|
106
|
+
s = String(s);
|
|
107
|
+
if (s.length >= n) return s;
|
|
108
|
+
return s + " ".repeat(n - s.length);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {{json:boolean, yes:boolean, force:boolean, dryRun:boolean, baseUrl?:string}} ctx
|
|
113
|
+
* @param {string[]} args
|
|
114
|
+
*/
|
|
115
|
+
export async function verifyCommand(ctx, args) {
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseArgs(args);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(e.message);
|
|
121
|
+
process.exit(2);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (parsed.help) { printHelp(); return; }
|
|
125
|
+
if (ctx && ctx.json) parsed.json = true;
|
|
126
|
+
|
|
127
|
+
const rawCwd = parsed.cwd || process.cwd();
|
|
128
|
+
const resolvedRoot = findShipRoot(rawCwd) || path.resolve(rawCwd);
|
|
129
|
+
|
|
130
|
+
const config = loadConfig(resolvedRoot);
|
|
131
|
+
const inventory = loadInventory(resolvedRoot);
|
|
132
|
+
const baseUrl = (ctx && ctx.baseUrl)
|
|
133
|
+
|| (config && config.api && config.api.base_url)
|
|
134
|
+
|| process.env.SHIP_API_BASE
|
|
135
|
+
|| "https://ship.elmundi.com";
|
|
136
|
+
|
|
137
|
+
const checkCtx = {
|
|
138
|
+
cwd: resolvedRoot,
|
|
139
|
+
config,
|
|
140
|
+
inventory,
|
|
141
|
+
baseUrl,
|
|
142
|
+
logger: (msg) => { if (!parsed.json) process.stderr.write(`${msg}\n`); },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const rows = await runChecks(checkCtx, {
|
|
146
|
+
filter: parsed.check.length ? parsed.check : null,
|
|
147
|
+
noNetwork: parsed.noNetwork,
|
|
148
|
+
});
|
|
149
|
+
const summary = summarize(rows);
|
|
150
|
+
const exitCode = summary.fail > 0 ? 1 : 0;
|
|
151
|
+
|
|
152
|
+
if (parsed.json) {
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
cwd: resolvedRoot,
|
|
157
|
+
checks: rows,
|
|
158
|
+
summary,
|
|
159
|
+
exit_code: exitCode,
|
|
160
|
+
},
|
|
161
|
+
null,
|
|
162
|
+
2,
|
|
163
|
+
) + "\n",
|
|
164
|
+
);
|
|
165
|
+
process.exit(exitCode);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const header = [`Ship verify — ${resolvedRoot}`, ""];
|
|
170
|
+
const visible = pickByStatus(rows, parsed.severity);
|
|
171
|
+
const idWidth = Math.max(
|
|
172
|
+
14,
|
|
173
|
+
...visible.map((r) => r.id.length),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const body = visible.map((r) => `${badge(r.status)} ${pad(r.id, idWidth)} ${r.detail}`);
|
|
177
|
+
const footer = [
|
|
178
|
+
"",
|
|
179
|
+
`${summary.total} check${summary.total === 1 ? "" : "s"} total: ${summary.pass} pass, ${summary.warn} warn, ${summary.fail} fail, ${summary.skip} skip`,
|
|
180
|
+
`Exit code: ${exitCode}${summary.fail ? " (any fail)" : ""}`,
|
|
181
|
+
];
|
|
182
|
+
if (!visible.length) {
|
|
183
|
+
body.push(`(no checks match --severity ${parsed.severity})`);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(`${header.concat(body, footer).join("\n")}\n`);
|
|
186
|
+
process.exit(exitCode);
|
|
187
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { DEFAULT_CONFIG } from "./schema.mjs";
|
|
6
|
+
|
|
7
|
+
export const SHIP_DIR = ".ship";
|
|
8
|
+
export const CONFIG_REL = path.join(SHIP_DIR, "config.yml");
|
|
9
|
+
export const STATE_REL = path.join(SHIP_DIR, "state.json");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Walk upward from startCwd looking for `.ship/config.yml`.
|
|
13
|
+
* Returns the directory containing `.ship/` or null.
|
|
14
|
+
* @param {string} startCwd
|
|
15
|
+
* @returns {string | null}
|
|
16
|
+
*/
|
|
17
|
+
export function findShipRoot(startCwd) {
|
|
18
|
+
let dir = path.resolve(startCwd || process.cwd());
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (fs.existsSync(path.join(dir, CONFIG_REL))) return dir;
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir) return null;
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable top-level and nested key order. Unknown keys are appended alphabetically.
|
|
29
|
+
*
|
|
30
|
+
* v2 introduces `agent` and `lanes` at the top level, plus nested keys
|
|
31
|
+
* under each lane. We keep them in their "natural reading order" so a
|
|
32
|
+
* human diffing two configs doesn't see a churn just because `shipctl
|
|
33
|
+
* config set` rewrote the file.
|
|
34
|
+
*/
|
|
35
|
+
const KEY_ORDER = {
|
|
36
|
+
__root: [
|
|
37
|
+
"version",
|
|
38
|
+
"shipctl_min",
|
|
39
|
+
"api",
|
|
40
|
+
"stack",
|
|
41
|
+
"agent",
|
|
42
|
+
"lanes",
|
|
43
|
+
"artifacts",
|
|
44
|
+
"cache",
|
|
45
|
+
"telemetry",
|
|
46
|
+
],
|
|
47
|
+
api: ["base_url", "channel", "ttl_hours", "offline_ok"],
|
|
48
|
+
stack: ["tracker", "ci", "agents", "agent", "language", "preset"],
|
|
49
|
+
"stack.agent": ["provider"],
|
|
50
|
+
agent: ["default", "overrides"],
|
|
51
|
+
"agent.default": ["provider"],
|
|
52
|
+
/* lanes.* is handled by LANE_KEY_ORDER below — each lane follows the
|
|
53
|
+
* same ordering regardless of its id. */
|
|
54
|
+
artifacts: ["pins", "auto_update"],
|
|
55
|
+
cache: ["vcs_tracked"],
|
|
56
|
+
telemetry: ["share", "anonymous_id", "scope"],
|
|
57
|
+
"telemetry.scope": ["artifact_usage", "improvement_drafts", "errors"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const LANE_KEY_ORDER = [
|
|
61
|
+
"kind",
|
|
62
|
+
"pattern",
|
|
63
|
+
"pattern_version",
|
|
64
|
+
"on",
|
|
65
|
+
"when",
|
|
66
|
+
"cron",
|
|
67
|
+
"cron_tz",
|
|
68
|
+
"idempotency",
|
|
69
|
+
"permissions",
|
|
70
|
+
"runner",
|
|
71
|
+
"timeout_minutes",
|
|
72
|
+
"concurrency",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const LANE_IDEMPOTENCY_KEY_ORDER = ["key", "store", "reset_on"];
|
|
76
|
+
|
|
77
|
+
function orderForPath(pathKey) {
|
|
78
|
+
/* lanes.<anything> — single lane entry, always share the same order */
|
|
79
|
+
if (/^lanes\.[^.]+$/.test(pathKey)) return LANE_KEY_ORDER;
|
|
80
|
+
if (/^lanes\.[^.]+\.idempotency$/.test(pathKey)) return LANE_IDEMPOTENCY_KEY_ORDER;
|
|
81
|
+
return KEY_ORDER[pathKey] || [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const USER_KEYED_LEAF_MAPS = new Set(["artifacts.pins", "agent.overrides"]);
|
|
85
|
+
const USER_KEYED_STRUCT_MAPS = new Set(["lanes"]);
|
|
86
|
+
|
|
87
|
+
function orderedCopy(obj, pathKey) {
|
|
88
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
|
89
|
+
|
|
90
|
+
/* User-keyed leaf map: keep author's key order, don't recurse (values
|
|
91
|
+
* are simple scalars like a semver string). */
|
|
92
|
+
if (USER_KEYED_LEAF_MAPS.has(pathKey)) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const k of Object.keys(obj)) out[k] = obj[k];
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* User-keyed struct map: keep author's key order, but recurse into
|
|
99
|
+
* each entry so its internal fields get normalised. */
|
|
100
|
+
if (USER_KEYED_STRUCT_MAPS.has(pathKey)) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const k of Object.keys(obj)) {
|
|
103
|
+
out[k] = orderedCopy(obj[k], `${pathKey}.${k}`);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const order = orderForPath(pathKey);
|
|
109
|
+
const remaining = new Set(Object.keys(obj));
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const k of order) {
|
|
112
|
+
if (remaining.has(k)) {
|
|
113
|
+
out[k] = obj[k];
|
|
114
|
+
remaining.delete(k);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const k of [...remaining].sort()) out[k] = obj[k];
|
|
118
|
+
|
|
119
|
+
for (const [k, v] of Object.entries(out)) {
|
|
120
|
+
const childKey = pathKey === "__root" ? k : pathKey ? `${pathKey}.${k}` : k;
|
|
121
|
+
out[k] = orderedCopy(v, childKey);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} cwd
|
|
128
|
+
* @returns {{config:object, filePath:string}}
|
|
129
|
+
*/
|
|
130
|
+
export function readConfig(cwd) {
|
|
131
|
+
const root = findShipRoot(cwd);
|
|
132
|
+
if (!root) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`.ship/config.yml not found (searched from ${path.resolve(cwd || process.cwd())} upward). Run 'shipctl config init' first.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const filePath = path.join(root, CONFIG_REL);
|
|
138
|
+
let text;
|
|
139
|
+
try {
|
|
140
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw new Error(`Failed to read ${filePath}: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = YAML.parse(text);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
throw new Error(`Failed to parse ${filePath}: ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
if (!parsed || typeof parsed !== "object") {
|
|
151
|
+
throw new Error(`${filePath}: top-level must be a YAML mapping`);
|
|
152
|
+
}
|
|
153
|
+
return { config: parsed, filePath };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} filePath
|
|
158
|
+
* @param {object} config
|
|
159
|
+
*/
|
|
160
|
+
export function writeConfig(filePath, config) {
|
|
161
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
162
|
+
const ordered = orderedCopy(JSON.parse(JSON.stringify(config)), "__root");
|
|
163
|
+
const body = YAML.stringify(ordered, {
|
|
164
|
+
lineWidth: 0,
|
|
165
|
+
indent: 2,
|
|
166
|
+
defaultStringType: "PLAIN",
|
|
167
|
+
});
|
|
168
|
+
const tmp = `${filePath}.tmp`;
|
|
169
|
+
fs.writeFileSync(tmp, body, "utf8");
|
|
170
|
+
fs.renameSync(tmp, filePath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate a fresh UUID v4 into config.telemetry.anonymous_id if missing/invalid.
|
|
175
|
+
* Mutates the config in place.
|
|
176
|
+
* @param {object} config
|
|
177
|
+
* @returns {string} the resulting anonymous_id
|
|
178
|
+
*/
|
|
179
|
+
export function ensureAnonymousId(config) {
|
|
180
|
+
if (!config.telemetry || typeof config.telemetry !== "object") config.telemetry = {};
|
|
181
|
+
const cur = config.telemetry.anonymous_id;
|
|
182
|
+
const valid =
|
|
183
|
+
typeof cur === "string" &&
|
|
184
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(cur);
|
|
185
|
+
if (!valid) config.telemetry.anonymous_id = randomUUID();
|
|
186
|
+
return config.telemetry.anonymous_id;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Default empty state for .ship/state.json.
|
|
191
|
+
*/
|
|
192
|
+
export function defaultState() {
|
|
193
|
+
return {
|
|
194
|
+
last_sync_at: null,
|
|
195
|
+
last_manifest_hash: null,
|
|
196
|
+
outbox_pending_count: 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} cwd
|
|
202
|
+
* @returns {{state:object, filePath:string}}
|
|
203
|
+
*/
|
|
204
|
+
export function readState(cwd) {
|
|
205
|
+
const root = findShipRoot(cwd);
|
|
206
|
+
if (!root) throw new Error(".ship/ not found; run 'shipctl config init' first.");
|
|
207
|
+
const filePath = path.join(root, STATE_REL);
|
|
208
|
+
if (!fs.existsSync(filePath)) return { state: defaultState(), filePath };
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
211
|
+
return { state: { ...defaultState(), ...(parsed || {}) }, filePath };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
throw new Error(`Failed to parse ${filePath}: ${e.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {string} cwd
|
|
219
|
+
* @param {object} state
|
|
220
|
+
*/
|
|
221
|
+
export function writeState(cwd, state) {
|
|
222
|
+
const root = findShipRoot(cwd);
|
|
223
|
+
if (!root) throw new Error(".ship/ not found; run 'shipctl config init' first.");
|
|
224
|
+
const filePath = path.join(root, STATE_REL);
|
|
225
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
226
|
+
const tmp = `${filePath}.tmp`;
|
|
227
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
228
|
+
fs.renameSync(tmp, filePath);
|
|
229
|
+
return filePath;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { DEFAULT_CONFIG };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl migrate` — convert `.ship/config.yml` from v1 to v2.
|
|
3
|
+
*
|
|
4
|
+
* v2 introduces the `lanes` map, `agent.default/overrides`, and deprecates
|
|
5
|
+
* the `workflow` artifact kind. The migration is deliberately conservative:
|
|
6
|
+
*
|
|
7
|
+
* - Every v1 field we still recognise is copied verbatim, not rewritten.
|
|
8
|
+
* - `stack.agent.provider` is lifted into `agent.default.provider`; we
|
|
9
|
+
* leave `stack.agent` intact so rolling back to an old shipctl still
|
|
10
|
+
* finds the field where v1 expected it.
|
|
11
|
+
* - The legacy `lanes:` list-of-strings (a shape customers wrote by
|
|
12
|
+
* hand between 0.9.x and 0.11.x — not a formal v1 field but widely
|
|
13
|
+
* present on disk) is translated into the v2 `lanes:` map using the
|
|
14
|
+
* preset defaults table below.
|
|
15
|
+
* - Unknown keys survive at the top level; v2 validation emits a
|
|
16
|
+
* warning but does not drop them.
|
|
17
|
+
*
|
|
18
|
+
* The migration is idempotent: running it against a v2 config returns
|
|
19
|
+
* the input untouched.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
CONFIG_SCHEMA_VERSION,
|
|
24
|
+
DEFAULT_PROCESS_CONFIG,
|
|
25
|
+
LEGACY_CONFIG_SCHEMA_VERSION,
|
|
26
|
+
} from "./schema.mjs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default lane translations for the well-known v1 `lanes:` list entries.
|
|
30
|
+
* Each entry maps the v1 string id to a v2 lane body. These were the
|
|
31
|
+
* four lane ids the `monorepo`, `web-app`, and `api-backend` presets
|
|
32
|
+
* bundled; anything outside this table is left for the caller to fill
|
|
33
|
+
* in manually (the migrator warns and adds a stub).
|
|
34
|
+
*
|
|
35
|
+
* Keep these aligned with `artifacts/collections/preset-*` and with
|
|
36
|
+
* RFC-0007 §"Lane-id reservations".
|
|
37
|
+
*/
|
|
38
|
+
const V1_LANE_DEFAULTS = Object.freeze({
|
|
39
|
+
pr_review: {
|
|
40
|
+
kind: "event",
|
|
41
|
+
pattern: "flow-pr-self-review",
|
|
42
|
+
on: "pull_request",
|
|
43
|
+
permissions: { contents: "read", "pull-requests": "write" },
|
|
44
|
+
},
|
|
45
|
+
daily_standup: {
|
|
46
|
+
kind: "schedule",
|
|
47
|
+
pattern: "flow-daily-retro",
|
|
48
|
+
cron: "0 9 * * 1-5",
|
|
49
|
+
},
|
|
50
|
+
tech_debt: {
|
|
51
|
+
kind: "schedule",
|
|
52
|
+
pattern: "flow-learning-capture",
|
|
53
|
+
cron: "0 10 * * 1",
|
|
54
|
+
},
|
|
55
|
+
self_heal: {
|
|
56
|
+
kind: "event",
|
|
57
|
+
pattern: "op-workflow-self-heal",
|
|
58
|
+
on: "workflow_run",
|
|
59
|
+
when: { conclusion: "failure" },
|
|
60
|
+
permissions: { contents: "read", actions: "read", "pull-requests": "write" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {{
|
|
66
|
+
* migrated: boolean,
|
|
67
|
+
* config: object,
|
|
68
|
+
* warnings: string[],
|
|
69
|
+
* stub_lanes: string[],
|
|
70
|
+
* }} MigrationResult
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Migrate a parsed config object from v1 to v2. Returns a fresh config
|
|
75
|
+
* object (input is not mutated) plus any non-fatal warnings.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} input
|
|
78
|
+
* @returns {MigrationResult}
|
|
79
|
+
*/
|
|
80
|
+
export function migrateV1ToV2(input) {
|
|
81
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
82
|
+
throw new Error("migrate: config must be a mapping");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (input.version === CONFIG_SCHEMA_VERSION) {
|
|
86
|
+
return {
|
|
87
|
+
migrated: false,
|
|
88
|
+
config: input,
|
|
89
|
+
warnings: ["config already at v2; nothing to do"],
|
|
90
|
+
stub_lanes: [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (input.version !== LEGACY_CONFIG_SCHEMA_VERSION) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`migrate: unsupported source version ${JSON.stringify(input.version)}; only v${LEGACY_CONFIG_SCHEMA_VERSION} is supported`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Deep-clone first — never touch the caller's object. YAML parse
|
|
100
|
+
* output is pure JSON so JSON round-trip is safe. */
|
|
101
|
+
const src = JSON.parse(JSON.stringify(input));
|
|
102
|
+
const warnings = [];
|
|
103
|
+
const stubLanes = [];
|
|
104
|
+
|
|
105
|
+
const out = {
|
|
106
|
+
version: CONFIG_SCHEMA_VERSION,
|
|
107
|
+
/* Bump the hard floor to the release that introduces v2. Anyone
|
|
108
|
+
* stuck on <0.12 will fail loudly on read instead of silently
|
|
109
|
+
* pretending v2 is v1. */
|
|
110
|
+
shipctl_min: bumpFloor(src.shipctl_min, "0.12.0"),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/* api / stack / cache / telemetry / artifacts survive verbatim — v2
|
|
114
|
+
* only added new top-level siblings. Preserve unknown keys too so a
|
|
115
|
+
* future field added by a newer shipctl on the same config doesn't
|
|
116
|
+
* get eaten by an older migrator. */
|
|
117
|
+
for (const k of Object.keys(src)) {
|
|
118
|
+
if (k === "version" || k === "shipctl_min") continue;
|
|
119
|
+
if (k === "lanes") continue; /* handled below */
|
|
120
|
+
out[k] = src[k];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* agent.default / agent.overrides */
|
|
124
|
+
out.agent = out.agent && typeof out.agent === "object" ? out.agent : {};
|
|
125
|
+
if (!out.agent.default || typeof out.agent.default !== "object") {
|
|
126
|
+
out.agent.default = { provider: null };
|
|
127
|
+
}
|
|
128
|
+
if (!out.agent.overrides || typeof out.agent.overrides !== "object") {
|
|
129
|
+
out.agent.overrides = {};
|
|
130
|
+
}
|
|
131
|
+
/* Lift stack.agent.provider into agent.default.provider if unset.
|
|
132
|
+
* We intentionally leave the original value in place so v1 readers
|
|
133
|
+
* keep working; v2 readers prefer agent.default.provider anyway. */
|
|
134
|
+
const liftedProvider = src.stack?.agent?.provider ?? null;
|
|
135
|
+
if (liftedProvider && !out.agent.default.provider) {
|
|
136
|
+
out.agent.default.provider = liftedProvider;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!out.process || typeof out.process !== "object" || Array.isArray(out.process)) {
|
|
140
|
+
out.process = cloneDefault(DEFAULT_PROCESS_CONFIG());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* lanes: translate from the legacy list-of-strings shape. */
|
|
144
|
+
out.lanes = {};
|
|
145
|
+
const srcLanes = src.lanes;
|
|
146
|
+
if (Array.isArray(srcLanes)) {
|
|
147
|
+
for (const laneId of srcLanes) {
|
|
148
|
+
if (typeof laneId !== "string") {
|
|
149
|
+
warnings.push(`lanes: skipped non-string entry ${JSON.stringify(laneId)}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const normalised = laneId.trim();
|
|
153
|
+
if (!normalised) continue;
|
|
154
|
+
const def = V1_LANE_DEFAULTS[normalised];
|
|
155
|
+
if (def) {
|
|
156
|
+
out.lanes[normalised] = cloneDefault(def);
|
|
157
|
+
} else {
|
|
158
|
+
/* Unknown v1 lane — emit a stub so the customer sees exactly
|
|
159
|
+
* which fields need attention on the next `shipctl doctor`. */
|
|
160
|
+
out.lanes[normalised] = {
|
|
161
|
+
kind: "schedule",
|
|
162
|
+
pattern: `TODO-pattern-for-${normalised}`,
|
|
163
|
+
cron: "TODO",
|
|
164
|
+
};
|
|
165
|
+
stubLanes.push(normalised);
|
|
166
|
+
warnings.push(
|
|
167
|
+
`lanes.${normalised}: no preset mapping; wrote a stub (fill in kind/pattern/cron before shipping)`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} else if (srcLanes && typeof srcLanes === "object") {
|
|
172
|
+
/* Already a map (e.g. someone hand-edited partway). Copy as-is;
|
|
173
|
+
* the v2 validator will flag any malformed lanes on the next
|
|
174
|
+
* `shipctl doctor` or write. */
|
|
175
|
+
out.lanes = JSON.parse(JSON.stringify(srcLanes));
|
|
176
|
+
} else if (srcLanes !== undefined) {
|
|
177
|
+
warnings.push(
|
|
178
|
+
`lanes: unexpected v1 shape ${typeof srcLanes}; dropped. Add lanes manually or rerun 'shipctl init'.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
migrated: true,
|
|
184
|
+
config: out,
|
|
185
|
+
warnings,
|
|
186
|
+
stub_lanes: stubLanes,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function cloneDefault(laneBody) {
|
|
191
|
+
return JSON.parse(JSON.stringify(laneBody));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse a semver-ish "X.Y.Z" and return the higher of the current floor
|
|
196
|
+
* and the minimum the caller wants. If `current` is not a string or
|
|
197
|
+
* doesn't parse, we fall back to the minimum unconditionally — an
|
|
198
|
+
* unreadable floor is no floor.
|
|
199
|
+
*
|
|
200
|
+
* @param {unknown} current
|
|
201
|
+
* @param {string} minimum
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
function bumpFloor(current, minimum) {
|
|
205
|
+
if (typeof current !== "string") return minimum;
|
|
206
|
+
const cur = parseSemver(current);
|
|
207
|
+
const min = parseSemver(minimum);
|
|
208
|
+
if (!cur || !min) return minimum;
|
|
209
|
+
return compareSemver(cur, min) >= 0 ? current : minimum;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseSemver(s) {
|
|
213
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(s).trim());
|
|
214
|
+
if (!m) return null;
|
|
215
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function compareSemver(a, b) {
|
|
219
|
+
for (let i = 0; i < 3; i += 1) {
|
|
220
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
221
|
+
}
|
|
222
|
+
return 0;
|
|
223
|
+
}
|