@elmundi/ship-cli 0.8.0 → 0.11.2
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 +456 -32
- package/bin/shipctl.mjs +165 -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 +373 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +302 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +1 -1
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +96 -21
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +368 -0
- package/lib/commands/lanes.mjs +502 -0
- package/lib/commands/manifest-catalog.mjs +102 -38
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +9 -43
- package/lib/commands/run.mjs +617 -0
- package/lib/commands/sync.mjs +749 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +215 -0
- package/lib/config/schema.mjs +650 -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
package/lib/commands/init.mjs
CHANGED
|
@@ -2,222 +2,894 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
-
import
|
|
6
|
-
import { MARKER, cursorRuleMdc, markdownSection, standaloneDoc } from "../templates.mjs";
|
|
5
|
+
import YAML from "yaml";
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_CONFIG,
|
|
9
|
+
validateConfig,
|
|
10
|
+
TRACKERS,
|
|
11
|
+
CIS,
|
|
12
|
+
PRESETS,
|
|
13
|
+
LANGUAGES,
|
|
14
|
+
CHANNELS,
|
|
15
|
+
AGENT_IDS,
|
|
16
|
+
} from "../config/schema.mjs";
|
|
17
|
+
import {
|
|
18
|
+
writeConfig,
|
|
19
|
+
writeState,
|
|
20
|
+
defaultState,
|
|
21
|
+
ensureAnonymousId,
|
|
22
|
+
findShipRoot,
|
|
23
|
+
readConfig,
|
|
24
|
+
SHIP_DIR,
|
|
25
|
+
CONFIG_REL,
|
|
26
|
+
STATE_REL,
|
|
27
|
+
} from "../config/io.mjs";
|
|
28
|
+
import { syncArtifacts } from "./sync.mjs";
|
|
29
|
+
import { detectAll } from "../adapters/index.mjs";
|
|
30
|
+
import { listCached, readCached } from "../cache/store.mjs";
|
|
31
|
+
import { renderPlan, applyPlan } from "../bootstrap/render.mjs";
|
|
32
|
+
import { KNOWN_AGENTS } from "../detect.mjs";
|
|
33
|
+
|
|
34
|
+
const MARKER = "<!-- ship-cli: artifacts-protocol v1 -->";
|
|
35
|
+
const END_MARKER = "<!-- ship-cli:end artifacts-protocol -->";
|
|
36
|
+
const INSTALLED_FROM_RE = /<!--\s*ship-cli:\s*installed-from\s+([^\s>]+)\s*-->/g;
|
|
37
|
+
const FOOTER_VERSION_RE = /@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* cwd:string,
|
|
42
|
+
* agents:string[],
|
|
43
|
+
* tracker:string|null,
|
|
44
|
+
* ci:string|null,
|
|
45
|
+
* preset:string|null,
|
|
46
|
+
* language:string|null,
|
|
47
|
+
* channel:string|null,
|
|
48
|
+
* telemetry:"on"|"off"|"ask"|null,
|
|
49
|
+
* copyRules:boolean,
|
|
50
|
+
* copyPlaybook:boolean,
|
|
51
|
+
* bootstrap:boolean,
|
|
52
|
+
* json:boolean,
|
|
53
|
+
* yes:boolean,
|
|
54
|
+
* force:boolean,
|
|
55
|
+
* dryRun:boolean
|
|
56
|
+
* }} InitOptions
|
|
57
|
+
*/
|
|
9
58
|
|
|
10
59
|
/**
|
|
11
|
-
* @param {{
|
|
60
|
+
* @param {{baseUrl:string, yes:boolean, force:boolean, dryRun:boolean, json:boolean}} ctx
|
|
12
61
|
* @param {string[]} args
|
|
13
62
|
*/
|
|
14
63
|
export async function initCommand(ctx, args) {
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
ship init [--yes] [--force] [--dry-run] [--only <id>] [--cwd <dir>]
|
|
18
|
-
|
|
19
|
-
Writes Cursor rules and/or markdown sections that point agents at the Ship methodology API
|
|
20
|
-
(base URL from SHIP_API_BASE or --base-url, default http://127.0.0.1:8100).
|
|
21
|
-
|
|
22
|
-
Flags:
|
|
23
|
-
--dry-run Show the plan only (recommended before first use).
|
|
24
|
-
--yes Non-interactive: apply immediately. In CI or scripts there is no prompt;
|
|
25
|
-
combine with --dry-run first if you are unsure. --force replaces existing
|
|
26
|
-
ship-cli blocks; without --force, existing injections are skipped.
|
|
27
|
-
--force Replace existing injected blocks.
|
|
28
|
-
--only cursor | agents-md | claude-md | codex | copilot
|
|
29
|
-
--cwd Target repository root (default: current directory).
|
|
30
|
-
|
|
31
|
-
If stdin is not a TTY and you omit --yes, init exits with an error unless you use --dry-run.`);
|
|
64
|
+
if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
|
|
65
|
+
printInitHelp();
|
|
32
66
|
return;
|
|
33
67
|
}
|
|
34
68
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
const opts = parseInitArgs(args, ctx);
|
|
70
|
+
validateFlagEnums(opts);
|
|
71
|
+
|
|
72
|
+
// ── Load existing config if present; else build a fresh one ──────────────
|
|
73
|
+
const shipRootBefore = findShipRoot(opts.cwd);
|
|
74
|
+
let config;
|
|
75
|
+
let configFilePath;
|
|
76
|
+
let configExisted = false;
|
|
77
|
+
if (shipRootBefore) {
|
|
78
|
+
try {
|
|
79
|
+
const read = readConfig(opts.cwd);
|
|
80
|
+
config = read.config;
|
|
81
|
+
configFilePath = read.filePath;
|
|
82
|
+
configExisted = true;
|
|
83
|
+
} catch {
|
|
84
|
+
config = DEFAULT_CONFIG();
|
|
85
|
+
configFilePath = path.join(opts.cwd, CONFIG_REL);
|
|
47
86
|
}
|
|
87
|
+
} else {
|
|
88
|
+
config = DEFAULT_CONFIG();
|
|
89
|
+
configFilePath = path.join(opts.cwd, CONFIG_REL);
|
|
48
90
|
}
|
|
49
91
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
const flagSet = {
|
|
93
|
+
tracker: opts.tracker !== null,
|
|
94
|
+
ci: opts.ci !== null,
|
|
95
|
+
preset: opts.preset !== null,
|
|
96
|
+
agents: opts.agents.length > 0,
|
|
97
|
+
language: opts.language !== null,
|
|
98
|
+
channel: opts.channel !== null,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
applyFlagOverrides(config, opts, flagSet);
|
|
102
|
+
|
|
103
|
+
// ── Telemetry decision ───────────────────────────────────────────────────
|
|
104
|
+
let telemetryMode;
|
|
105
|
+
if (opts.telemetry === "on") {
|
|
106
|
+
config.telemetry.share = true;
|
|
107
|
+
telemetryMode = "on";
|
|
108
|
+
} else if (opts.telemetry === "off") {
|
|
109
|
+
config.telemetry.share = false;
|
|
110
|
+
telemetryMode = "off";
|
|
111
|
+
} else if (opts.telemetry === "ask" && input.isTTY && output.isTTY && !opts.dryRun) {
|
|
112
|
+
telemetryMode = (await promptTelemetry()) ? "on" : "off";
|
|
113
|
+
config.telemetry.share = telemetryMode === "on";
|
|
114
|
+
} else if (opts.yes) {
|
|
115
|
+
config.telemetry.share = false;
|
|
116
|
+
telemetryMode = "off";
|
|
117
|
+
} else if (!opts.dryRun && input.isTTY && output.isTTY && !configExisted) {
|
|
118
|
+
telemetryMode = (await promptTelemetry()) ? "on" : "off";
|
|
119
|
+
config.telemetry.share = telemetryMode === "on";
|
|
120
|
+
} else {
|
|
121
|
+
// Non-TTY (e.g. CI), or config already existed — keep whatever was there;
|
|
122
|
+
// never auto-enable. Default `share` is false via DEFAULT_CONFIG().
|
|
123
|
+
telemetryMode = config.telemetry.share === true ? "on" : "off";
|
|
71
124
|
}
|
|
72
125
|
|
|
73
|
-
|
|
74
|
-
const plan = [];
|
|
126
|
+
ensureAnonymousId(config);
|
|
75
127
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
128
|
+
// ── Doctor-based inference (no network) for anything left at defaults ────
|
|
129
|
+
let findings = null;
|
|
130
|
+
try {
|
|
131
|
+
findings = await detectAll(opts.cwd);
|
|
132
|
+
} catch {
|
|
133
|
+
findings = null;
|
|
80
134
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
if (findings) {
|
|
136
|
+
const proposed = proposeStack(findings);
|
|
137
|
+
if (!flagSet.tracker && (config.stack.tracker == null || config.stack.tracker === "none")) {
|
|
138
|
+
config.stack.tracker = proposed.tracker;
|
|
84
139
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
140
|
+
if (!flagSet.ci && (config.stack.ci == null || config.stack.ci === "manual")) {
|
|
141
|
+
config.stack.ci = proposed.ci;
|
|
87
142
|
}
|
|
88
|
-
if (
|
|
89
|
-
|
|
143
|
+
if (!flagSet.language && (config.stack.language == null || config.stack.language === "multi")) {
|
|
144
|
+
config.stack.language = proposed.language;
|
|
90
145
|
}
|
|
91
|
-
if (
|
|
92
|
-
|
|
146
|
+
if (
|
|
147
|
+
!flagSet.agents &&
|
|
148
|
+
(!Array.isArray(config.stack.agents) || config.stack.agents.length === 0)
|
|
149
|
+
) {
|
|
150
|
+
config.stack.agents = proposed.agents;
|
|
93
151
|
}
|
|
94
152
|
}
|
|
95
153
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
});
|
|
154
|
+
// Final validation
|
|
155
|
+
const valid = validateConfig(config);
|
|
156
|
+
if (!valid.ok) {
|
|
157
|
+
for (const w of valid.warnings) process.stderr.write(`warn: ${w}\n`);
|
|
158
|
+
for (const e of valid.errors) process.stderr.write(`${e}\n`);
|
|
159
|
+
process.exit(10);
|
|
103
160
|
}
|
|
161
|
+
for (const w of valid.warnings) process.stderr.write(`warn: ${w}\n`);
|
|
104
162
|
|
|
105
|
-
|
|
106
|
-
|
|
163
|
+
// ── Derived artifact list to fetch via syncArtifacts ─────────────────────
|
|
164
|
+
const derived = buildDerivedList(config, opts);
|
|
165
|
+
|
|
166
|
+
// ── Dry-run short-circuit: emit plan only, write nothing ─────────────────
|
|
167
|
+
if (opts.dryRun) {
|
|
168
|
+
const plan = buildPlanSummary(opts.cwd, config, opts, telemetryMode, derived);
|
|
169
|
+
if (opts.json) {
|
|
170
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
171
|
+
} else {
|
|
172
|
+
printHumanPlan(plan, opts);
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write("(dry-run: no files written)\n");
|
|
107
175
|
return;
|
|
108
176
|
}
|
|
109
177
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (ctx.dryRun) {
|
|
117
|
-
console.log("(dry-run: no files written)");
|
|
118
|
-
return;
|
|
178
|
+
// ── Write config / state / cache dir / .gitignore ────────────────────────
|
|
179
|
+
const ensured = ensureShipLayout(opts.cwd, config, configFilePath, configExisted);
|
|
180
|
+
const shipRoot = findShipRoot(opts.cwd);
|
|
181
|
+
if (!shipRoot) {
|
|
182
|
+
throw new Error("init: failed to locate .ship/ after creation");
|
|
119
183
|
}
|
|
120
184
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
185
|
+
// ── Sync relevant artifacts into .ship/cache/ ────────────────────────────
|
|
186
|
+
let syncSummary = null;
|
|
187
|
+
if (derived.length) {
|
|
188
|
+
try {
|
|
189
|
+
syncSummary = await syncArtifacts({
|
|
190
|
+
cwd: shipRoot,
|
|
191
|
+
baseUrl: ctx.baseUrl,
|
|
192
|
+
channel: opts.channel || config.api?.channel,
|
|
193
|
+
onlyKinds: ["collection"],
|
|
194
|
+
include: derived,
|
|
195
|
+
verbose: false,
|
|
196
|
+
});
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const msg = e && e.message ? e.message : String(e);
|
|
199
|
+
process.stderr.write(`warn: artifact fetch partially failed (${msg})\n`);
|
|
125
200
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── --copy-rules: materialize agent rules from cache onto disk ───────────
|
|
204
|
+
const ruleInstallations = [];
|
|
205
|
+
if (opts.copyRules) {
|
|
206
|
+
for (const agent of config.stack.agents || []) {
|
|
207
|
+
const res = installAgentRule(shipRoot, agent, { force: opts.force });
|
|
208
|
+
if (res) ruleInstallations.push(res);
|
|
132
209
|
}
|
|
133
210
|
}
|
|
134
211
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
212
|
+
// ── --bootstrap: render CI/tracker scaffolding ───────────────────────────
|
|
213
|
+
let bootstrapSummary = null;
|
|
214
|
+
if (opts.bootstrap) {
|
|
215
|
+
const presetArtifact = readPresetArtifact(shipRoot, config);
|
|
216
|
+
const plan = renderPlan(config, presetArtifact);
|
|
217
|
+
const results = applyPlan(shipRoot, plan, { dryRun: false, force: opts.force });
|
|
218
|
+
bootstrapSummary = { files: plan.summary.files, notes: plan.summary.notes, results };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── --copy-playbook: copy cached playbook to .ship/playbooks/ ────────────
|
|
222
|
+
let playbookCopied = null;
|
|
223
|
+
if (opts.copyPlaybook) {
|
|
224
|
+
playbookCopied = copyPlaybookFromCache(shipRoot);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Output ───────────────────────────────────────────────────────────────
|
|
228
|
+
const summary = {
|
|
229
|
+
ok: true,
|
|
230
|
+
cwd: opts.cwd,
|
|
231
|
+
ship_root: shipRoot,
|
|
232
|
+
config_path: ensured.configFilePath,
|
|
233
|
+
telemetry: telemetryMode,
|
|
234
|
+
stack: {
|
|
235
|
+
tracker: config.stack.tracker,
|
|
236
|
+
ci: config.stack.ci,
|
|
237
|
+
preset: config.stack.preset,
|
|
238
|
+
language: config.stack.language,
|
|
239
|
+
agents: [...(config.stack.agents || [])],
|
|
240
|
+
},
|
|
241
|
+
channel: config.api?.channel || "stable",
|
|
242
|
+
rules: ruleInstallations,
|
|
243
|
+
bootstrap: bootstrapSummary,
|
|
244
|
+
playbook: playbookCopied,
|
|
245
|
+
sync: syncSummary
|
|
246
|
+
? {
|
|
247
|
+
up_to_date: syncSummary.up_to_date,
|
|
248
|
+
updated: syncSummary.updated,
|
|
249
|
+
failed: syncSummary.failed,
|
|
250
|
+
entries: syncSummary.entries,
|
|
251
|
+
}
|
|
252
|
+
: null,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (opts.json) {
|
|
256
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
printHumanSummary(summary, ensured, opts);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Arg parsing ────────────────────────────────────────────────────────────
|
|
264
|
+
/**
|
|
265
|
+
* @param {string[]} args
|
|
266
|
+
* @param {{yes:boolean,force:boolean,dryRun:boolean,json:boolean}} ctx
|
|
267
|
+
* @returns {InitOptions}
|
|
268
|
+
*/
|
|
269
|
+
function parseInitArgs(args, ctx) {
|
|
270
|
+
/** @type {InitOptions} */
|
|
271
|
+
const opts = {
|
|
272
|
+
cwd: process.cwd(),
|
|
273
|
+
agents: [],
|
|
274
|
+
tracker: null,
|
|
275
|
+
ci: null,
|
|
276
|
+
preset: null,
|
|
277
|
+
language: null,
|
|
278
|
+
channel: null,
|
|
279
|
+
telemetry: null,
|
|
280
|
+
copyRules: false,
|
|
281
|
+
copyPlaybook: false,
|
|
282
|
+
bootstrap: false,
|
|
283
|
+
json: !!ctx.json,
|
|
284
|
+
yes: !!ctx.yes,
|
|
285
|
+
force: !!ctx.force,
|
|
286
|
+
dryRun: !!ctx.dryRun,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const agentsCsv = [];
|
|
290
|
+
|
|
291
|
+
for (let i = 0; i < args.length; i++) {
|
|
292
|
+
const a = args[i];
|
|
293
|
+
if (a === "--cwd" && args[i + 1]) {
|
|
294
|
+
opts.cwd = path.resolve(String(args[++i]));
|
|
295
|
+
continue;
|
|
138
296
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
297
|
+
if (a.startsWith("--cwd=")) {
|
|
298
|
+
opts.cwd = path.resolve(a.slice("--cwd=".length));
|
|
299
|
+
continue;
|
|
141
300
|
}
|
|
142
|
-
if (
|
|
143
|
-
|
|
301
|
+
if (a === "--yes" || a === "-y") {
|
|
302
|
+
opts.yes = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (a === "--force") {
|
|
306
|
+
opts.force = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (a === "--dry-run") {
|
|
310
|
+
opts.dryRun = true;
|
|
311
|
+
continue;
|
|
144
312
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
313
|
+
if (a === "--json") {
|
|
314
|
+
opts.json = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (a === "--agents" && args[i + 1]) {
|
|
318
|
+
for (const s of String(args[++i]).split(",")) {
|
|
319
|
+
const id = s.trim();
|
|
320
|
+
if (id) agentsCsv.push(id);
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (a === "--tracker" && args[i + 1]) {
|
|
325
|
+
opts.tracker = String(args[++i]);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (a === "--ci" && args[i + 1]) {
|
|
329
|
+
opts.ci = String(args[++i]);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (a === "--preset" && args[i + 1]) {
|
|
333
|
+
opts.preset = String(args[++i]);
|
|
334
|
+
continue;
|
|
147
335
|
}
|
|
148
|
-
if (
|
|
149
|
-
|
|
336
|
+
if (a === "--language" && args[i + 1]) {
|
|
337
|
+
opts.language = String(args[++i]);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (a === "--channel" && args[i + 1]) {
|
|
341
|
+
opts.channel = String(args[++i]);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (a === "--telemetry" && args[i + 1]) {
|
|
345
|
+
const v = String(args[++i]).trim().toLowerCase();
|
|
346
|
+
if (v !== "on" && v !== "off" && v !== "ask") {
|
|
347
|
+
process.stderr.write(`init: --telemetry must be on|off|ask (got "${v}")\n`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
opts.telemetry = /** @type {"on"|"off"|"ask"} */ (v);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (a === "--copy-rules") {
|
|
354
|
+
opts.copyRules = true;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (a === "--copy-playbook") {
|
|
358
|
+
opts.copyPlaybook = true;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (a === "--bootstrap") {
|
|
362
|
+
opts.bootstrap = true;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
opts.agents = agentsCsv;
|
|
368
|
+
return opts;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** @param {InitOptions} opts */
|
|
372
|
+
function validateFlagEnums(opts) {
|
|
373
|
+
if (opts.tracker && !TRACKERS.includes(opts.tracker)) {
|
|
374
|
+
process.stderr.write(`init: unknown --tracker "${opts.tracker}". Allowed: ${TRACKERS.join(", ")}\n`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
if (opts.ci && !CIS.includes(opts.ci)) {
|
|
378
|
+
process.stderr.write(`init: unknown --ci "${opts.ci}". Allowed: ${CIS.join(", ")}\n`);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
if (opts.preset && !PRESETS.includes(opts.preset)) {
|
|
382
|
+
process.stderr.write(`init: unknown --preset "${opts.preset}". Allowed: ${PRESETS.join(", ")}\n`);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
if (opts.language && !LANGUAGES.includes(opts.language)) {
|
|
386
|
+
process.stderr.write(`init: unknown --language "${opts.language}". Allowed: ${LANGUAGES.join(", ")}\n`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
if (opts.channel && !CHANNELS.includes(opts.channel)) {
|
|
390
|
+
process.stderr.write(`init: unknown --channel "${opts.channel}". Allowed: ${CHANNELS.join(", ")}\n`);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
for (const a of opts.agents) {
|
|
394
|
+
if (!AGENT_IDS.includes(a)) {
|
|
395
|
+
process.stderr.write(
|
|
396
|
+
`init: unknown agent "${a}". Allowed: ${AGENT_IDS.slice().sort().join(", ")}\n`,
|
|
397
|
+
);
|
|
398
|
+
process.exit(1);
|
|
150
399
|
}
|
|
151
400
|
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** @param {object} config @param {InitOptions} opts @param {Record<string,boolean>} flagSet */
|
|
404
|
+
function applyFlagOverrides(config, opts, flagSet) {
|
|
405
|
+
if (!config.stack || typeof config.stack !== "object") config.stack = {};
|
|
406
|
+
if (!config.api || typeof config.api !== "object") config.api = {};
|
|
407
|
+
if (!config.telemetry || typeof config.telemetry !== "object") config.telemetry = {};
|
|
152
408
|
|
|
153
|
-
if (
|
|
154
|
-
|
|
409
|
+
if (flagSet.tracker) config.stack.tracker = opts.tracker;
|
|
410
|
+
if (flagSet.ci) config.stack.ci = opts.ci;
|
|
411
|
+
if (flagSet.preset) config.stack.preset = opts.preset;
|
|
412
|
+
if (flagSet.language) config.stack.language = opts.language;
|
|
413
|
+
if (flagSet.agents) config.stack.agents = [...opts.agents];
|
|
414
|
+
if (flagSet.channel) config.api.channel = opts.channel;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── Doctor / stack inference ───────────────────────────────────────────────
|
|
418
|
+
function proposeStack(findings) {
|
|
419
|
+
const pickTop = (arr, { min = 0.1, exclude = [], fallback = null } = {}) => {
|
|
420
|
+
const pool = arr.filter(
|
|
421
|
+
(e) => e.present && e.confidence > min && !exclude.includes(e.id),
|
|
422
|
+
);
|
|
423
|
+
return pool.length ? pool[0].id : fallback;
|
|
424
|
+
};
|
|
425
|
+
const tracker = pickTop(findings.trackers, { exclude: ["none"], fallback: "none" });
|
|
426
|
+
const ci = pickTop(findings.ci, { exclude: ["manual"], fallback: "manual" });
|
|
427
|
+
const language = pickTop(findings.language, { fallback: "multi" }) || "multi";
|
|
428
|
+
const agents = (findings.agents || [])
|
|
429
|
+
.filter((a) => a.present && a.confidence >= 0.5)
|
|
430
|
+
.map((a) => a.id)
|
|
431
|
+
.filter((id) => AGENT_IDS.includes(id));
|
|
432
|
+
return { tracker, ci, language, agents };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Derived artifact list ──────────────────────────────────────────────────
|
|
436
|
+
/**
|
|
437
|
+
* @param {object} config
|
|
438
|
+
* @param {InitOptions} opts
|
|
439
|
+
* @returns {Array<{kind:string,id:string}>}
|
|
440
|
+
*/
|
|
441
|
+
function buildDerivedList(config, opts) {
|
|
442
|
+
const list = [];
|
|
443
|
+
const seen = new Set();
|
|
444
|
+
const add = (kind, id) => {
|
|
445
|
+
const key = `${kind}:${id}`;
|
|
446
|
+
if (seen.has(key)) return;
|
|
447
|
+
seen.add(key);
|
|
448
|
+
list.push({ kind, id });
|
|
449
|
+
};
|
|
450
|
+
for (const a of config.stack.agents || []) {
|
|
451
|
+
add("collection", `agent-rules-${a}`);
|
|
452
|
+
}
|
|
453
|
+
if (config.stack.preset) {
|
|
454
|
+
add("collection", `preset-${config.stack.preset}`);
|
|
455
|
+
}
|
|
456
|
+
if (opts.copyPlaybook) {
|
|
457
|
+
add("collection", "adoption-playbook");
|
|
458
|
+
}
|
|
459
|
+
return list;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Ship layout creation ───────────────────────────────────────────────────
|
|
463
|
+
function ensureShipLayout(cwd, config, configFilePath, configExisted) {
|
|
464
|
+
const shipDir = path.join(cwd, SHIP_DIR);
|
|
465
|
+
fs.mkdirSync(shipDir, { recursive: true });
|
|
466
|
+
|
|
467
|
+
let configWritten = false;
|
|
468
|
+
if (!configExisted) {
|
|
469
|
+
writeConfig(configFilePath, config);
|
|
470
|
+
configWritten = true;
|
|
471
|
+
} else {
|
|
472
|
+
// Persist flag-driven updates back to disk when config already existed.
|
|
473
|
+
writeConfig(configFilePath, config);
|
|
474
|
+
configWritten = true;
|
|
155
475
|
}
|
|
156
476
|
|
|
157
|
-
|
|
477
|
+
const statePath = path.join(cwd, STATE_REL);
|
|
478
|
+
if (!fs.existsSync(statePath)) {
|
|
479
|
+
writeState(cwd, defaultState());
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const cacheDir = path.join(shipDir, "cache");
|
|
483
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
484
|
+
const keep = path.join(cacheDir, ".gitkeep");
|
|
485
|
+
if (!fs.existsSync(keep)) fs.writeFileSync(keep, "", "utf8");
|
|
486
|
+
|
|
487
|
+
const giResult = ensureGitignore(cwd);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
configFilePath,
|
|
491
|
+
configWritten,
|
|
492
|
+
configExisted,
|
|
493
|
+
gitignorePath: giResult.path,
|
|
494
|
+
gitignoreChanged: giResult.changed,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function ensureGitignore(cwd) {
|
|
499
|
+
const giPath = path.join(cwd, ".gitignore");
|
|
500
|
+
const entries = [
|
|
501
|
+
"# Ship",
|
|
502
|
+
".ship/cache/",
|
|
503
|
+
".ship/telemetry-outbox.jsonl",
|
|
504
|
+
".ship/feedback-drafts/",
|
|
505
|
+
".ship/state.json",
|
|
506
|
+
];
|
|
507
|
+
let current = "";
|
|
508
|
+
if (fs.existsSync(giPath)) current = fs.readFileSync(giPath, "utf8");
|
|
509
|
+
const existingLines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
510
|
+
const toAppend = entries.filter((e) => !existingLines.has(e.trim()));
|
|
511
|
+
if (toAppend.length === 0) return { path: giPath, changed: false };
|
|
512
|
+
const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
513
|
+
const tail =
|
|
514
|
+
current.length === 0 ? `${toAppend.join("\n")}\n` : `${prefix}${toAppend.join("\n")}\n`;
|
|
515
|
+
fs.writeFileSync(giPath, current + tail, "utf8");
|
|
516
|
+
return { path: giPath, changed: true };
|
|
158
517
|
}
|
|
159
518
|
|
|
519
|
+
// ── Agent rule installation ────────────────────────────────────────────────
|
|
160
520
|
/**
|
|
161
|
-
* @param {string}
|
|
162
|
-
* @param {
|
|
521
|
+
* @param {string} shipRoot
|
|
522
|
+
* @param {string} agent
|
|
523
|
+
* @param {{force:boolean}} opts
|
|
524
|
+
* @returns {null | {agent:string, path:string, action:string, from:string}}
|
|
163
525
|
*/
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
526
|
+
function installAgentRule(shipRoot, agent, { force }) {
|
|
527
|
+
const id = `agent-rules-${agent}`;
|
|
528
|
+
const version = latestCachedVersion(shipRoot, "collection", id);
|
|
529
|
+
if (!version) {
|
|
530
|
+
process.stderr.write(
|
|
531
|
+
`warn: --copy-rules: no cached artifact for collection/${id} (was the fetch successful?)\n`,
|
|
532
|
+
);
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
const cached = readCached(shipRoot, "collection", id, version);
|
|
536
|
+
if (!cached) return null;
|
|
537
|
+
const parsed = parseFrontmatter(cached.content);
|
|
538
|
+
const target = parsed.attrs.install_target || fallbackInstallTarget(agent);
|
|
539
|
+
if (!target) {
|
|
540
|
+
process.stderr.write(`warn: --copy-rules: no install_target for ${agent}; skipping\n`);
|
|
541
|
+
return null;
|
|
171
542
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
543
|
+
const marker = parsed.attrs.marker || MARKER;
|
|
544
|
+
const body = parsed.body.trimEnd();
|
|
545
|
+
const footer = `<!-- ship-cli: installed-from collection/${id}@${version} -->`;
|
|
546
|
+
|
|
547
|
+
const absTarget = path.isAbsolute(target) ? target : path.join(shipRoot, target);
|
|
548
|
+
const existed = fs.existsSync(absTarget);
|
|
549
|
+
const prev = existed ? fs.readFileSync(absTarget, "utf8") : "";
|
|
550
|
+
|
|
551
|
+
// If installed-from references a different version and --force is not set, skip.
|
|
552
|
+
if (existed) {
|
|
553
|
+
const existingVersion = extractInstalledVersion(prev);
|
|
554
|
+
if (existingVersion && existingVersion !== version && !force) {
|
|
555
|
+
process.stderr.write(
|
|
556
|
+
`warn: ${path.relative(shipRoot, absTarget)} has ship-cli installed-from @${existingVersion}; pass --force to replace with @${version}\n`,
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
agent,
|
|
560
|
+
path: path.relative(shipRoot, absTarget) || absTarget,
|
|
561
|
+
action: "skipped",
|
|
562
|
+
from: `collection/${id}@${version}`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const next = upsertMarkedBlock(prev, { marker, endMarker: END_MARKER, body, footer });
|
|
568
|
+
|
|
569
|
+
fs.mkdirSync(path.dirname(absTarget), { recursive: true });
|
|
570
|
+
fs.writeFileSync(absTarget, next, "utf8");
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
agent,
|
|
574
|
+
path: path.relative(shipRoot, absTarget) || absTarget,
|
|
575
|
+
action: existed ? "updated" : "wrote",
|
|
576
|
+
from: `collection/${id}@${version}`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function fallbackInstallTarget(agent) {
|
|
581
|
+
const meta = KNOWN_AGENTS[agent];
|
|
582
|
+
if (!meta) return null;
|
|
583
|
+
return path.join(...meta.targetRel);
|
|
176
584
|
}
|
|
177
585
|
|
|
178
586
|
/**
|
|
179
|
-
*
|
|
180
|
-
* @param {string}
|
|
181
|
-
* @
|
|
587
|
+
* Parse a YAML front-matter block if present.
|
|
588
|
+
* @param {string} text
|
|
589
|
+
* @returns {{attrs:object, body:string}}
|
|
182
590
|
*/
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
if (fs.existsSync(filePath)) prev = fs.readFileSync(filePath, "utf8");
|
|
187
|
-
if (prev.includes(MARKER) && !ctx.force) {
|
|
188
|
-
console.log(`skip (already injected): ${filePath}`);
|
|
189
|
-
return;
|
|
591
|
+
function parseFrontmatter(text) {
|
|
592
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) {
|
|
593
|
+
return { attrs: {}, body: text };
|
|
190
594
|
}
|
|
191
|
-
|
|
192
|
-
|
|
595
|
+
const rest = text.slice(4);
|
|
596
|
+
const endIdx = rest.indexOf("\n---\n");
|
|
597
|
+
const altEndIdx = rest.indexOf("\n---\r\n");
|
|
598
|
+
const end = endIdx >= 0 ? endIdx : altEndIdx >= 0 ? altEndIdx : -1;
|
|
599
|
+
if (end < 0) return { attrs: {}, body: text };
|
|
600
|
+
const fmText = rest.slice(0, end);
|
|
601
|
+
const body = rest.slice(end + (endIdx >= 0 ? 5 : 6));
|
|
602
|
+
let attrs = {};
|
|
603
|
+
try {
|
|
604
|
+
const parsed = YAML.parse(fmText);
|
|
605
|
+
if (parsed && typeof parsed === "object") attrs = parsed;
|
|
606
|
+
} catch {
|
|
607
|
+
attrs = {};
|
|
193
608
|
}
|
|
194
|
-
|
|
195
|
-
const next = prev.replace(/\s+$/, "") + (prev ? "\n" : "") + block;
|
|
196
|
-
fs.writeFileSync(filePath, next, "utf8");
|
|
197
|
-
console.log(`updated ${filePath}`);
|
|
609
|
+
return { attrs, body };
|
|
198
610
|
}
|
|
199
611
|
|
|
200
612
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
613
|
+
* Idempotent upsert of a marker-delimited block + footer line.
|
|
614
|
+
* prev : current file text (may be empty)
|
|
615
|
+
* marker, endMarker : <!-- … --> tokens
|
|
616
|
+
* body : replacement body (should contain or wrap with the markers itself;
|
|
617
|
+
* we just splice it in verbatim)
|
|
618
|
+
* footer : single line `<!-- ship-cli: installed-from … -->`
|
|
204
619
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
620
|
+
function upsertMarkedBlock(prev, { marker, endMarker, body, footer }) {
|
|
621
|
+
// Strip every existing "installed-from" footer so we never duplicate them.
|
|
622
|
+
let stripped = prev.replace(INSTALLED_FROM_RE, "");
|
|
623
|
+
// Collapse any 3+ consecutive newlines left by the strip.
|
|
624
|
+
stripped = stripped.replace(/\n{3,}/g, "\n\n");
|
|
625
|
+
|
|
626
|
+
let out;
|
|
627
|
+
if (stripped.includes(marker) && stripped.includes(endMarker)) {
|
|
628
|
+
const start = stripped.indexOf(marker);
|
|
629
|
+
const endAt = stripped.indexOf(endMarker, start) + endMarker.length;
|
|
630
|
+
const before = stripped.slice(0, start).replace(/\s+$/, "");
|
|
631
|
+
const after = stripped.slice(endAt).replace(/^\s+/, "");
|
|
632
|
+
out = `${before}${before ? "\n\n" : ""}${body.trim()}\n${after ? `\n${after}` : ""}`;
|
|
633
|
+
} else if (stripped.trim().length === 0) {
|
|
634
|
+
out = `${body.trim()}\n`;
|
|
635
|
+
} else {
|
|
636
|
+
out = `${stripped.replace(/\s+$/, "")}\n\n${body.trim()}\n`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Append a single footer line.
|
|
640
|
+
out = `${out.replace(/\s+$/, "")}\n\n${footer}\n`;
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function extractInstalledVersion(text) {
|
|
645
|
+
const matches = [...text.matchAll(INSTALLED_FROM_RE)];
|
|
646
|
+
if (!matches.length) return null;
|
|
647
|
+
const last = matches[matches.length - 1][1];
|
|
648
|
+
const m = last.match(FOOTER_VERSION_RE);
|
|
649
|
+
return m ? m[1] : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── Cache helpers ──────────────────────────────────────────────────────────
|
|
653
|
+
function latestCachedVersion(shipRoot, kind, id) {
|
|
654
|
+
const all = listCached(shipRoot).filter((c) => c.kind === kind && c.id === id);
|
|
655
|
+
if (!all.length) return null;
|
|
656
|
+
all.sort((a, b) => cmpSemver(b.version, a.version));
|
|
657
|
+
return all[0].version;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function cmpSemver(a, b) {
|
|
661
|
+
const pa = String(a).split(/[.-]/).map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
|
|
662
|
+
const pb = String(b).split(/[.-]/).map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
|
|
663
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
664
|
+
const xa = pa[i];
|
|
665
|
+
const xb = pb[i];
|
|
666
|
+
if (xa === undefined) return -1;
|
|
667
|
+
if (xb === undefined) return 1;
|
|
668
|
+
if (xa === xb) continue;
|
|
669
|
+
if (typeof xa === typeof xb) return xa < xb ? -1 : 1;
|
|
670
|
+
return typeof xa === "number" ? -1 : 1;
|
|
210
671
|
}
|
|
211
|
-
|
|
212
|
-
fs.writeFileSync(filePath, body, "utf8");
|
|
213
|
-
console.log(`wrote ${filePath}`);
|
|
672
|
+
return 0;
|
|
214
673
|
}
|
|
215
674
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
675
|
+
function readPresetArtifact(shipRoot, config) {
|
|
676
|
+
const preset = config.stack?.preset;
|
|
677
|
+
if (!preset) return null;
|
|
678
|
+
const id = `preset-${preset}`;
|
|
679
|
+
const version = latestCachedVersion(shipRoot, "collection", id);
|
|
680
|
+
if (!version) return null;
|
|
681
|
+
const cached = readCached(shipRoot, "collection", id, version);
|
|
682
|
+
if (!cached) return null;
|
|
683
|
+
const { attrs, body } = parseFrontmatter(cached.content);
|
|
684
|
+
return { id, version, attrs, body };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function copyPlaybookFromCache(shipRoot) {
|
|
688
|
+
const version = latestCachedVersion(shipRoot, "collection", "adoption-playbook");
|
|
689
|
+
if (!version) return null;
|
|
690
|
+
const cached = readCached(shipRoot, "collection", "adoption-playbook", version);
|
|
691
|
+
if (!cached) return null;
|
|
692
|
+
const dest = path.join(shipRoot, ".ship", "playbooks", `adoption-playbook@${version}.md`);
|
|
693
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
694
|
+
fs.writeFileSync(dest, cached.content, "utf8");
|
|
695
|
+
return { path: path.relative(shipRoot, dest), version };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Telemetry prompt ───────────────────────────────────────────────────────
|
|
699
|
+
async function promptTelemetry() {
|
|
700
|
+
const rl = readline.createInterface({ input, output });
|
|
701
|
+
try {
|
|
702
|
+
const ans = (
|
|
703
|
+
await rl.question(
|
|
704
|
+
"Share anonymous artifact usage with Ship to improve the methodology? [y/N] ",
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
.trim()
|
|
708
|
+
.toLowerCase();
|
|
709
|
+
return ans === "y" || ans === "yes";
|
|
710
|
+
} finally {
|
|
711
|
+
rl.close();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── Plan summary (dry-run) ─────────────────────────────────────────────────
|
|
716
|
+
function buildPlanSummary(cwd, config, opts, telemetryMode, derived) {
|
|
717
|
+
const stack = {
|
|
718
|
+
tracker: config.stack.tracker,
|
|
719
|
+
ci: config.stack.ci,
|
|
720
|
+
preset: config.stack.preset,
|
|
721
|
+
language: config.stack.language,
|
|
722
|
+
agents: [...(config.stack.agents || [])],
|
|
723
|
+
};
|
|
724
|
+
const rules = opts.copyRules
|
|
725
|
+
? (config.stack.agents || []).map((a) => ({
|
|
726
|
+
agent: a,
|
|
727
|
+
path:
|
|
728
|
+
(KNOWN_AGENTS[a] && KNOWN_AGENTS[a].targetRel.join("/")) ||
|
|
729
|
+
`.ship/rules/${a}.md`,
|
|
730
|
+
from: `collection/agent-rules-${a}@<latest>`,
|
|
731
|
+
}))
|
|
732
|
+
: [];
|
|
733
|
+
const bootstrapPreview = opts.bootstrap
|
|
734
|
+
? renderPlan(config, null).summary
|
|
735
|
+
: null;
|
|
736
|
+
return {
|
|
737
|
+
ok: true,
|
|
738
|
+
dry_run: true,
|
|
739
|
+
cwd,
|
|
740
|
+
config_path: path.join(cwd, CONFIG_REL),
|
|
741
|
+
telemetry: telemetryMode,
|
|
742
|
+
channel: config.api?.channel || "stable",
|
|
743
|
+
stack,
|
|
744
|
+
artifacts_to_fetch: derived,
|
|
745
|
+
rules,
|
|
746
|
+
bootstrap: bootstrapPreview,
|
|
747
|
+
playbook: opts.copyPlaybook ? { requested: true, fetched: false } : null,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function printHumanPlan(plan, opts) {
|
|
752
|
+
const lines = [];
|
|
753
|
+
lines.push("Ship init — planned changes");
|
|
754
|
+
lines.push("---------------------------");
|
|
755
|
+
lines.push(`cwd: ${plan.cwd}`);
|
|
756
|
+
lines.push(`config: ${plan.config_path}`);
|
|
757
|
+
lines.push(`telemetry: ${plan.telemetry}`);
|
|
758
|
+
lines.push(`channel: ${plan.channel}`);
|
|
759
|
+
lines.push(
|
|
760
|
+
`stack: tracker=${plan.stack.tracker} ci=${plan.stack.ci} preset=${plan.stack.preset} language=${plan.stack.language}`,
|
|
761
|
+
);
|
|
762
|
+
lines.push(`agents: ${plan.stack.agents.join(", ") || "(none)"}`);
|
|
763
|
+
if (plan.artifacts_to_fetch.length) {
|
|
764
|
+
lines.push("");
|
|
765
|
+
lines.push("Artifacts to fetch:");
|
|
766
|
+
for (const a of plan.artifacts_to_fetch) lines.push(` - ${a.kind}/${a.id}`);
|
|
767
|
+
}
|
|
768
|
+
if (plan.rules.length) {
|
|
769
|
+
lines.push("");
|
|
770
|
+
lines.push("Rules to install (--copy-rules):");
|
|
771
|
+
for (const r of plan.rules) lines.push(` - ${r.path} (${r.from})`);
|
|
772
|
+
}
|
|
773
|
+
if (plan.bootstrap) {
|
|
774
|
+
lines.push("");
|
|
775
|
+
lines.push("Bootstrap plan:");
|
|
776
|
+
for (const f of plan.bootstrap.files) lines.push(` - ${f.mode}: ${f.path}`);
|
|
777
|
+
}
|
|
778
|
+
if (plan.playbook) {
|
|
779
|
+
lines.push("");
|
|
780
|
+
lines.push("--copy-playbook: requested (fetched during real run only)");
|
|
781
|
+
}
|
|
782
|
+
if (!opts.copyRules) {
|
|
783
|
+
lines.push("");
|
|
784
|
+
lines.push("(--copy-rules not set: rules files will NOT be installed)");
|
|
785
|
+
}
|
|
786
|
+
process.stdout.write(`${lines.join("\n")}\n\n`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Human summary (real run) ───────────────────────────────────────────────
|
|
790
|
+
function printHumanSummary(summary, ensured, opts) {
|
|
791
|
+
const lines = [];
|
|
792
|
+
lines.push("Ship init complete");
|
|
793
|
+
lines.push("-----------------");
|
|
794
|
+
lines.push(`Config: ${path.relative(summary.cwd, summary.config_path) || summary.config_path}`);
|
|
795
|
+
lines.push(
|
|
796
|
+
`Agents: ${summary.stack.agents.length ? summary.stack.agents.join(", ") : "(none)"}`,
|
|
797
|
+
);
|
|
798
|
+
lines.push(`Tracker: ${summary.stack.tracker}`);
|
|
799
|
+
lines.push(`CI: ${summary.stack.ci}`);
|
|
800
|
+
lines.push(`Preset: ${summary.stack.preset}`);
|
|
801
|
+
lines.push(`Channel: ${summary.channel}`);
|
|
802
|
+
lines.push(`Telemetry: ${summary.telemetry}`);
|
|
803
|
+
|
|
804
|
+
if (summary.rules.length) {
|
|
805
|
+
lines.push("");
|
|
806
|
+
lines.push("Installed rules:");
|
|
807
|
+
for (const r of summary.rules) {
|
|
808
|
+
lines.push(` - ${r.action} ${r.path} (from ${r.from})`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (summary.bootstrap) {
|
|
813
|
+
lines.push("");
|
|
814
|
+
lines.push(`Bootstrap (preset=${summary.stack.preset}):`);
|
|
815
|
+
for (const r of summary.bootstrap.results) {
|
|
816
|
+
lines.push(` - ${r.action}: ${r.path}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (summary.playbook) {
|
|
821
|
+
lines.push("");
|
|
822
|
+
lines.push(`Playbook: wrote ${summary.playbook.path} (@${summary.playbook.version})`);
|
|
823
|
+
} else if (opts.copyPlaybook) {
|
|
824
|
+
lines.push("");
|
|
825
|
+
lines.push("Playbook: not found on manifest (skipped)");
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (summary.sync) {
|
|
829
|
+
lines.push("");
|
|
830
|
+
lines.push(
|
|
831
|
+
`Sync: up_to_date=${summary.sync.up_to_date} updated=${summary.sync.updated} failed=${summary.sync.failed}`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
lines.push("");
|
|
836
|
+
lines.push("Next:");
|
|
837
|
+
lines.push(" shipctl sync # keep artifacts fresh");
|
|
838
|
+
lines.push(" shipctl verify # check tracker labels, CI secrets, rules markers");
|
|
839
|
+
lines.push(" shipctl feedback draft # submit improvement idea");
|
|
840
|
+
|
|
841
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── Help ──────────────────────────────────────────────────────────────────-
|
|
845
|
+
function printInitHelp() {
|
|
846
|
+
const agentsList = AGENT_IDS.slice().sort().join(", ");
|
|
847
|
+
process.stdout.write(`shipctl init — bootstrap .ship/, fetch artifacts, install agent rules.
|
|
848
|
+
|
|
849
|
+
USAGE
|
|
850
|
+
shipctl init [--yes] [--force] [--dry-run] [--cwd DIR] [--json]
|
|
851
|
+
[--agents cursor,codex,claude-md]
|
|
852
|
+
[--tracker <name>] [--ci <name>] [--preset <name>]
|
|
853
|
+
[--language <id>] [--channel stable|edge]
|
|
854
|
+
[--copy-rules] [--copy-playbook] [--bootstrap]
|
|
855
|
+
[--telemetry on|off|ask]
|
|
856
|
+
|
|
857
|
+
FLAGS
|
|
858
|
+
--yes Non-interactive: skip confirmation prompts.
|
|
859
|
+
--force Replace existing ship-managed blocks with current content.
|
|
860
|
+
--dry-run Preview only; no files written, no network writes.
|
|
861
|
+
--json Emit the final summary as a JSON object (stdout).
|
|
862
|
+
--cwd DIR Operate against DIR instead of the current working dir.
|
|
863
|
+
--agents <csv> Comma-separated agent ids. Example: cursor,codex,claude-md.
|
|
864
|
+
--tracker <name> Stack tracker: ${TRACKERS.join("|")}
|
|
865
|
+
--ci <name> Stack CI: ${CIS.join("|")}
|
|
866
|
+
--preset <name> Stack preset: ${PRESETS.join("|")}
|
|
867
|
+
--language <id> Repo language: ${LANGUAGES.join("|")}
|
|
868
|
+
--channel <c> Override config.api.channel: ${CHANNELS.join("|")}
|
|
869
|
+
--copy-rules Install collection/agent-rules-<agent>@<v> from cache to its install_target.
|
|
870
|
+
--copy-playbook Try to fetch collection/adoption-playbook and copy it under .ship/playbooks/.
|
|
871
|
+
--bootstrap Also render CI/tracker scaffolding (SHIP_BOOTSTRAP_PLAN.md etc.).
|
|
872
|
+
--telemetry Explicit telemetry choice (default: prompt on first init, off in --yes / non-TTY).
|
|
873
|
+
|
|
874
|
+
BEHAVIOR
|
|
875
|
+
1. Ensures .ship/ exists and writes config.yml + state.json + cache/.gitkeep + .gitignore.
|
|
876
|
+
2. Runs built-in adapter detection (doctor --no-network) to propose any stack fields
|
|
877
|
+
the flags / existing config left at defaults.
|
|
878
|
+
3. Calls shipctl sync for collection/agent-rules-<agent> + collection/preset-<preset>.
|
|
879
|
+
4. With --copy-rules, installs each cached rules artifact to its install_target,
|
|
880
|
+
preserving unrelated content and marker-guarded sections. Re-runs are idempotent;
|
|
881
|
+
--force replaces a previously-installed different version.
|
|
882
|
+
5. With --bootstrap, renders CI/tracker skeletons from the preset artifact
|
|
883
|
+
(full support: mobile-app + gh-actions + linear; plan-only otherwise).
|
|
884
|
+
|
|
885
|
+
KNOWN AGENT IDS
|
|
886
|
+
${agentsList}
|
|
887
|
+
|
|
888
|
+
EXAMPLES
|
|
889
|
+
shipctl init --yes --agents cursor,claude-md --copy-rules --telemetry off
|
|
890
|
+
shipctl init --yes --bootstrap --agents cursor,codex --tracker linear \\
|
|
891
|
+
--ci gh-actions --preset mobile-app --copy-rules
|
|
892
|
+
shipctl init --dry-run --agents cursor --copy-rules --bootstrap \\
|
|
893
|
+
--preset mobile-app --ci gh-actions --tracker linear
|
|
894
|
+
`);
|
|
223
895
|
}
|