@namewta/speculo 0.6.1 → 0.7.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 +7 -10
- package/dist/src/cli.js +36 -128
- package/dist/src/cli.js.map +1 -1
- package/dist/src/index.d.ts +2 -4
- package/dist/src/index.js +151 -165
- package/dist/src/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.speculo/README.md +4 -0
- package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +1 -1
- package/dist/src/migrate.d.ts +0 -51
- package/dist/src/migrate.js +0 -939
- package/dist/src/migrate.js.map +0 -1
- package/dist/src/skills-mirror.d.ts +0 -38
- package/dist/src/skills-mirror.js +0 -160
- package/dist/src/skills-mirror.js.map +0 -1
package/dist/src/migrate.js
DELETED
|
@@ -1,939 +0,0 @@
|
|
|
1
|
-
import { cp, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
-
import { pathExists } from "./utils.js";
|
|
4
|
-
const V2_MARKERS = [
|
|
5
|
-
"dev-status.json",
|
|
6
|
-
"doc-status.json",
|
|
7
|
-
"person-status.json",
|
|
8
|
-
".config",
|
|
9
|
-
"archive",
|
|
10
|
-
"dev",
|
|
11
|
-
"doc",
|
|
12
|
-
];
|
|
13
|
-
const TRANSITIONAL_DOCS_STATE = join("commands", ".config", "docs-sync-state.json");
|
|
14
|
-
const SPECDEV_STATUS = join("specdev", "status.json");
|
|
15
|
-
const COMMAND_RUN_RE = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
16
|
-
const LEGACY_ROOT_ENTRIES = new Set([
|
|
17
|
-
"AGENTS.md",
|
|
18
|
-
".config",
|
|
19
|
-
"archive",
|
|
20
|
-
"commands",
|
|
21
|
-
"dev",
|
|
22
|
-
"doc",
|
|
23
|
-
"person",
|
|
24
|
-
"dev-status.json",
|
|
25
|
-
"doc-status.json",
|
|
26
|
-
"person-status.json",
|
|
27
|
-
".DS_Store",
|
|
28
|
-
]);
|
|
29
|
-
const CHANGE_NAME_RE = /^(\d{4})-(\d{2})-(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
30
|
-
function validateSpecdevStatusV4(status) {
|
|
31
|
-
const errors = [];
|
|
32
|
-
const expectedTopLevel = ["active", "archived", "schema_version", "workflow"];
|
|
33
|
-
const actualTopLevel = Object.keys(status).sort();
|
|
34
|
-
if (JSON.stringify(actualTopLevel) !== JSON.stringify(expectedTopLevel)) {
|
|
35
|
-
errors.push("top-level fields must be schema_version, workflow, active, and archived");
|
|
36
|
-
}
|
|
37
|
-
if (status.schema_version !== 4 || status.workflow !== "specdev") {
|
|
38
|
-
errors.push("schema_version must be 4 and workflow must be specdev");
|
|
39
|
-
}
|
|
40
|
-
if (!Array.isArray(status.active) || !Array.isArray(status.archived)) {
|
|
41
|
-
errors.push("active and archived must be arrays");
|
|
42
|
-
return errors;
|
|
43
|
-
}
|
|
44
|
-
const activeNames = new Set();
|
|
45
|
-
for (const entry of status.active) {
|
|
46
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
47
|
-
errors.push("active entries must be objects");
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
const value = entry;
|
|
51
|
-
const allowed = new Set([
|
|
52
|
-
"change",
|
|
53
|
-
"current_work",
|
|
54
|
-
"works_run",
|
|
55
|
-
"claimed_investigations",
|
|
56
|
-
]);
|
|
57
|
-
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
|
58
|
-
errors.push("active entries contain unsupported fields");
|
|
59
|
-
}
|
|
60
|
-
if (typeof value.change !== "string" || !CHANGE_NAME_RE.test(value.change)) {
|
|
61
|
-
errors.push("active change names must use YYYY-MM-DD-kebab format");
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (activeNames.has(value.change)) {
|
|
65
|
-
errors.push("active change names must be unique: " + value.change);
|
|
66
|
-
}
|
|
67
|
-
activeNames.add(value.change);
|
|
68
|
-
if (value.current_work !== null &&
|
|
69
|
-
(typeof value.current_work !== "string" ||
|
|
70
|
-
!value.current_work.startsWith("specdev/"))) {
|
|
71
|
-
errors.push("current_work must be null or a specdev work id: " + value.change);
|
|
72
|
-
}
|
|
73
|
-
if (!Array.isArray(value.works_run) ||
|
|
74
|
-
value.works_run.some((work) => typeof work !== "string" || !work.startsWith("specdev/")) ||
|
|
75
|
-
new Set(value.works_run).size !== value.works_run.length) {
|
|
76
|
-
errors.push("works_run must contain unique specdev work ids: " + value.change);
|
|
77
|
-
}
|
|
78
|
-
if (value.claimed_investigations !== undefined) {
|
|
79
|
-
if (Array.isArray(value.claimed_investigations)) {
|
|
80
|
-
const allowedClaimKeys = new Set(["id", "owner", "session", "claimed_at"]);
|
|
81
|
-
if (value.claimed_investigations.some((claim) => claim &&
|
|
82
|
-
typeof claim === "object" &&
|
|
83
|
-
!Array.isArray(claim) &&
|
|
84
|
-
Object.keys(claim).some((key) => !allowedClaimKeys.has(key)))) {
|
|
85
|
-
errors.push("investigation claims contain unsupported fields: " + value.change);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const claimErrors = [];
|
|
89
|
-
mergeClaims([], value.claimed_investigations, value.change, claimErrors);
|
|
90
|
-
if (claimErrors.length > 0)
|
|
91
|
-
errors.push(...claimErrors);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
const archivedNames = new Set();
|
|
95
|
-
for (const change of status.archived) {
|
|
96
|
-
if (typeof change !== "string" || !CHANGE_NAME_RE.test(change)) {
|
|
97
|
-
errors.push("archived change names must use YYYY-MM-DD-kebab format");
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (archivedNames.has(change)) {
|
|
101
|
-
errors.push("archived change names must be unique: " + change);
|
|
102
|
-
}
|
|
103
|
-
if (activeNames.has(change)) {
|
|
104
|
-
errors.push("change appears in both active and archived: " + change);
|
|
105
|
-
}
|
|
106
|
-
archivedNames.add(change);
|
|
107
|
-
}
|
|
108
|
-
return errors;
|
|
109
|
-
}
|
|
110
|
-
function installRoot(target) {
|
|
111
|
-
return join(resolve(target), "speculo");
|
|
112
|
-
}
|
|
113
|
-
function stateRoot(target) {
|
|
114
|
-
return join(installRoot(target), ".speculo");
|
|
115
|
-
}
|
|
116
|
-
async function directoryNames(path) {
|
|
117
|
-
if (!(await pathExists(path)))
|
|
118
|
-
return [];
|
|
119
|
-
const entries = await readdir(path, { withFileTypes: true });
|
|
120
|
-
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
121
|
-
}
|
|
122
|
-
async function readStatus(path, blockers) {
|
|
123
|
-
if (!(await pathExists(path))) {
|
|
124
|
-
blockers.push("Missing change status: " + path);
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
return JSON.parse(await readFile(path, "utf8"));
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
blockers.push("Invalid change status JSON: " + path);
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
async function readJsonObject(path, blockers, label) {
|
|
136
|
-
if (!(await pathExists(path)))
|
|
137
|
-
return undefined;
|
|
138
|
-
try {
|
|
139
|
-
const value = JSON.parse(await readFile(path, "utf8"));
|
|
140
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
141
|
-
blockers.push(label + " must be a JSON object: " + path);
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
return value;
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
blockers.push("Invalid " + label + " JSON: " + path);
|
|
148
|
-
return undefined;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
function mergeClaims(target, source, change, blockers) {
|
|
152
|
-
if (source === undefined)
|
|
153
|
-
return;
|
|
154
|
-
if (!Array.isArray(source)) {
|
|
155
|
-
blockers.push("Invalid claimed_investigations for active change: " + change);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
const byId = new Map(target.map((claim) => [String(claim.id), claim]));
|
|
159
|
-
for (const claim of source) {
|
|
160
|
-
if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
|
|
161
|
-
blockers.push("Invalid investigation claim for active change: " + change);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
const value = claim;
|
|
165
|
-
if (typeof value.id !== "string" ||
|
|
166
|
-
typeof value.owner !== "string" ||
|
|
167
|
-
typeof value.claimed_at !== "string" ||
|
|
168
|
-
(value.session !== undefined &&
|
|
169
|
-
value.session !== null &&
|
|
170
|
-
typeof value.session !== "string")) {
|
|
171
|
-
blockers.push("Invalid investigation claim fields for active change: " + change);
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const normalized = {
|
|
175
|
-
id: value.id,
|
|
176
|
-
owner: value.owner,
|
|
177
|
-
claimed_at: value.claimed_at,
|
|
178
|
-
};
|
|
179
|
-
if (value.session !== undefined)
|
|
180
|
-
normalized.session = value.session;
|
|
181
|
-
const existing = byId.get(value.id);
|
|
182
|
-
if (existing && JSON.stringify(existing) !== JSON.stringify(normalized)) {
|
|
183
|
-
blockers.push("Conflicting investigation claim " + value.id + " for active change: " + change);
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
if (!existing) {
|
|
187
|
-
target.push(normalized);
|
|
188
|
-
byId.set(value.id, normalized);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
export async function migrateSpecdevStatusV3(status, root, blockers = []) {
|
|
193
|
-
if (status.schema_version !== 3 || status.workflow !== "specdev") {
|
|
194
|
-
blockers.push("SpecDev status migration requires schema_version 3 and workflow specdev");
|
|
195
|
-
}
|
|
196
|
-
if (!Array.isArray(status.active)) {
|
|
197
|
-
blockers.push("SpecDev v3 status active must be an array");
|
|
198
|
-
}
|
|
199
|
-
if (!Array.isArray(status.work_history)) {
|
|
200
|
-
blockers.push("SpecDev v3 status work_history must be an array");
|
|
201
|
-
}
|
|
202
|
-
if (!Array.isArray(status.completed)) {
|
|
203
|
-
blockers.push("SpecDev v3 status completed must be an array");
|
|
204
|
-
}
|
|
205
|
-
const activeByChange = new Map();
|
|
206
|
-
for (const entry of Array.isArray(status.active) ? status.active : []) {
|
|
207
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
208
|
-
blockers.push("SpecDev v3 status contains an invalid active entry");
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const value = entry;
|
|
212
|
-
if (typeof value.change !== "string" || !CHANGE_NAME_RE.test(value.change)) {
|
|
213
|
-
blockers.push("Invalid active change name in SpecDev v3 status");
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
if (value.current_work !== null &&
|
|
217
|
-
(typeof value.current_work !== "string" ||
|
|
218
|
-
!value.current_work.startsWith("specdev/"))) {
|
|
219
|
-
blockers.push("Invalid current_work for active change: " + value.change);
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
if (!Array.isArray(value.works_run) ||
|
|
223
|
-
value.works_run.some((work) => typeof work !== "string" || !work.startsWith("specdev/"))) {
|
|
224
|
-
blockers.push("Invalid works_run for active change: " + value.change);
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
const existing = activeByChange.get(value.change);
|
|
228
|
-
if (existing &&
|
|
229
|
-
existing.current_work !== value.current_work &&
|
|
230
|
-
existing.current_work !== null &&
|
|
231
|
-
value.current_work !== null) {
|
|
232
|
-
blockers.push("Conflicting current_work values for active change: " + value.change);
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
const migrated = existing ?? {
|
|
236
|
-
change: value.change,
|
|
237
|
-
current_work: value.current_work,
|
|
238
|
-
works_run: [],
|
|
239
|
-
};
|
|
240
|
-
if (migrated.current_work === null && typeof value.current_work === "string") {
|
|
241
|
-
migrated.current_work = value.current_work;
|
|
242
|
-
}
|
|
243
|
-
migrated.works_run = [
|
|
244
|
-
...new Set([...migrated.works_run, ...value.works_run]),
|
|
245
|
-
];
|
|
246
|
-
const claims = migrated.claimed_investigations ?? [];
|
|
247
|
-
mergeClaims(claims, value.claimed_investigations, value.change, blockers);
|
|
248
|
-
if (claims.length > 0 || value.claimed_investigations !== undefined) {
|
|
249
|
-
migrated.claimed_investigations = claims;
|
|
250
|
-
}
|
|
251
|
-
activeByChange.set(value.change, migrated);
|
|
252
|
-
}
|
|
253
|
-
const archived = [];
|
|
254
|
-
const archivedNames = new Set();
|
|
255
|
-
for (const entry of Array.isArray(status.completed) ? status.completed : []) {
|
|
256
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
257
|
-
blockers.push("SpecDev v3 status contains an invalid completed entry");
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
const change = entry.change;
|
|
261
|
-
if (typeof change !== "string" || !CHANGE_NAME_RE.test(change)) {
|
|
262
|
-
blockers.push("Invalid archived change name in SpecDev v3 status");
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
if (!archivedNames.has(change)) {
|
|
266
|
-
const archivePath = join(root, "specdev", "archive", monthFromName(change), change);
|
|
267
|
-
if (!(await pathExists(archivePath))) {
|
|
268
|
-
blockers.push("Archived change directory is missing: " + archivePath);
|
|
269
|
-
}
|
|
270
|
-
archived.push(change);
|
|
271
|
-
archivedNames.add(change);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
for (const change of activeByChange.keys()) {
|
|
275
|
-
if (archivedNames.has(change)) {
|
|
276
|
-
blockers.push("SpecDev change appears in both active and archived: " + change);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
const migrated = {
|
|
280
|
-
schema_version: 4,
|
|
281
|
-
workflow: "specdev",
|
|
282
|
-
active: [...activeByChange.values()],
|
|
283
|
-
archived,
|
|
284
|
-
};
|
|
285
|
-
blockers.push(...validateSpecdevStatusV4(migrated).map((issue) => "Migrated SpecDev status would be invalid: " + issue));
|
|
286
|
-
return migrated;
|
|
287
|
-
}
|
|
288
|
-
async function validateLegacyIndex(path, blockers) {
|
|
289
|
-
if (!(await pathExists(path)))
|
|
290
|
-
return;
|
|
291
|
-
try {
|
|
292
|
-
const value = JSON.parse(await readFile(path, "utf8"));
|
|
293
|
-
if (!value ||
|
|
294
|
-
typeof value !== "object" ||
|
|
295
|
-
!Array.isArray(value.active)) {
|
|
296
|
-
blockers.push("Invalid legacy workflow index shape: " + path);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
catch {
|
|
300
|
-
blockers.push("Invalid legacy workflow index JSON: " + path);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
async function readLegacyDocsSyncState(path, blockers) {
|
|
304
|
-
if (!(await pathExists(path)))
|
|
305
|
-
return undefined;
|
|
306
|
-
try {
|
|
307
|
-
const value = JSON.parse(await readFile(path, "utf8"));
|
|
308
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
309
|
-
blockers.push("Invalid legacy docs-sync state shape: " + path);
|
|
310
|
-
return undefined;
|
|
311
|
-
}
|
|
312
|
-
const state = value;
|
|
313
|
-
for (const key of ["tracked_docs", "tracked_assets", "synced_docs", "synced_assets"]) {
|
|
314
|
-
const entries = state[key];
|
|
315
|
-
if (entries !== undefined &&
|
|
316
|
-
(!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string"))) {
|
|
317
|
-
blockers.push("Invalid legacy docs-sync " + key + ": " + path);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
for (const key of ["last_sync_sha", "previous_sync_sha"]) {
|
|
321
|
-
const sha = state[key];
|
|
322
|
-
if (sha !== undefined && sha !== null && typeof sha !== "string") {
|
|
323
|
-
blockers.push("Invalid legacy docs-sync " + key + ": " + path);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return state;
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
blockers.push("Invalid legacy docs-sync state JSON: " + path);
|
|
330
|
-
return undefined;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
function legacyName(source, originalName) {
|
|
334
|
-
const match = originalName.match(CHANGE_NAME_RE);
|
|
335
|
-
if (!match)
|
|
336
|
-
return originalName;
|
|
337
|
-
return match[1] + "-" + match[2] + "-" + match[3] +
|
|
338
|
-
"-legacy-" + source + "-" + match[4];
|
|
339
|
-
}
|
|
340
|
-
function monthFromName(name) {
|
|
341
|
-
return name.slice(0, 7);
|
|
342
|
-
}
|
|
343
|
-
async function collectActiveChanges(root, workflow, blockers) {
|
|
344
|
-
const workflowRoot = join(root, workflow);
|
|
345
|
-
const names = await directoryNames(workflowRoot);
|
|
346
|
-
const changes = [];
|
|
347
|
-
for (const name of names) {
|
|
348
|
-
if (!CHANGE_NAME_RE.test(name)) {
|
|
349
|
-
blockers.push("Malformed legacy change directory: " + join(workflowRoot, name));
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
const status = await readStatus(join(workflowRoot, name, ".status.json"), blockers);
|
|
353
|
-
if (!status)
|
|
354
|
-
continue;
|
|
355
|
-
if (workflow === "person") {
|
|
356
|
-
changes.push({
|
|
357
|
-
sourceWorkflow: workflow,
|
|
358
|
-
sourcePath: join(workflowRoot, name),
|
|
359
|
-
originalName: name,
|
|
360
|
-
destinationRelative: join("person", "changes", name),
|
|
361
|
-
archived: false,
|
|
362
|
-
status,
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
const destinationName = legacyName(workflow, name);
|
|
367
|
-
changes.push({
|
|
368
|
-
sourceWorkflow: workflow,
|
|
369
|
-
sourcePath: join(workflowRoot, name),
|
|
370
|
-
originalName: name,
|
|
371
|
-
destinationRelative: join("specdev", "archive", monthFromName(name), destinationName),
|
|
372
|
-
archived: true,
|
|
373
|
-
status,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return changes;
|
|
378
|
-
}
|
|
379
|
-
async function collectArchivedChanges(root, workflow, blockers) {
|
|
380
|
-
const workflowArchive = join(root, "archive", workflow);
|
|
381
|
-
const months = await directoryNames(workflowArchive);
|
|
382
|
-
const changes = [];
|
|
383
|
-
for (const month of months) {
|
|
384
|
-
if (!/^\d{4}-\d{2}$/.test(month)) {
|
|
385
|
-
blockers.push("Malformed legacy archive month: " + join(workflowArchive, month));
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
for (const name of await directoryNames(join(workflowArchive, month))) {
|
|
389
|
-
if (!CHANGE_NAME_RE.test(name)) {
|
|
390
|
-
blockers.push("Malformed legacy archived change: " +
|
|
391
|
-
join(workflowArchive, month, name));
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const status = await readStatus(join(workflowArchive, month, name, ".status.json"), blockers);
|
|
395
|
-
if (!status)
|
|
396
|
-
continue;
|
|
397
|
-
if (workflow === "person") {
|
|
398
|
-
changes.push({
|
|
399
|
-
sourceWorkflow: workflow,
|
|
400
|
-
sourcePath: join(workflowArchive, month, name),
|
|
401
|
-
originalName: name,
|
|
402
|
-
destinationRelative: join("person", "archive", month, name),
|
|
403
|
-
archived: true,
|
|
404
|
-
status,
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
else {
|
|
408
|
-
const destinationName = legacyName(workflow, name);
|
|
409
|
-
changes.push({
|
|
410
|
-
sourceWorkflow: workflow,
|
|
411
|
-
sourcePath: join(workflowArchive, month, name),
|
|
412
|
-
originalName: name,
|
|
413
|
-
destinationRelative: join("specdev", "archive", month, destinationName),
|
|
414
|
-
archived: true,
|
|
415
|
-
status,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return changes;
|
|
421
|
-
}
|
|
422
|
-
export async function detectLegacyState(target) {
|
|
423
|
-
const root = stateRoot(target);
|
|
424
|
-
if (!(await pathExists(root)))
|
|
425
|
-
return false;
|
|
426
|
-
for (const marker of V2_MARKERS) {
|
|
427
|
-
if (await pathExists(join(root, marker)))
|
|
428
|
-
return true;
|
|
429
|
-
}
|
|
430
|
-
if (await pathExists(join(root, TRANSITIONAL_DOCS_STATE)))
|
|
431
|
-
return true;
|
|
432
|
-
if ((await legacyCommandRunNames(root)).length > 0)
|
|
433
|
-
return true;
|
|
434
|
-
const statusPath = join(root, SPECDEV_STATUS);
|
|
435
|
-
if (!(await pathExists(statusPath)))
|
|
436
|
-
return false;
|
|
437
|
-
try {
|
|
438
|
-
const value = JSON.parse(await readFile(statusPath, "utf8"));
|
|
439
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
440
|
-
return true;
|
|
441
|
-
const status = value;
|
|
442
|
-
return status.schema_version !== 4 || validateSpecdevStatusV4(status).length > 0;
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
return true;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
async function detectV2State(target) {
|
|
449
|
-
const root = stateRoot(target);
|
|
450
|
-
if (!(await pathExists(root)))
|
|
451
|
-
return false;
|
|
452
|
-
for (const marker of V2_MARKERS) {
|
|
453
|
-
if (await pathExists(join(root, marker)))
|
|
454
|
-
return true;
|
|
455
|
-
}
|
|
456
|
-
return false;
|
|
457
|
-
}
|
|
458
|
-
async function legacyCommandRunNames(root) {
|
|
459
|
-
const commandsRoot = join(root, "commands");
|
|
460
|
-
if (!(await pathExists(commandsRoot)))
|
|
461
|
-
return [];
|
|
462
|
-
const entries = await readdir(commandsRoot, { withFileTypes: true });
|
|
463
|
-
return entries
|
|
464
|
-
.filter((entry) => entry.isDirectory() && COMMAND_RUN_RE.test(entry.name))
|
|
465
|
-
.map((entry) => entry.name)
|
|
466
|
-
.sort();
|
|
467
|
-
}
|
|
468
|
-
export async function planMigration(target) {
|
|
469
|
-
const resolvedTarget = resolve(target);
|
|
470
|
-
const root = stateRoot(resolvedTarget);
|
|
471
|
-
const blockers = [];
|
|
472
|
-
const actions = [];
|
|
473
|
-
const changes = [];
|
|
474
|
-
const v2Detected = await detectV2State(resolvedTarget);
|
|
475
|
-
const transitionalDetected = (await pathExists(join(root, TRANSITIONAL_DOCS_STATE))) ||
|
|
476
|
-
(await legacyCommandRunNames(root)).length > 0;
|
|
477
|
-
const specdevStatus = await readJsonObject(join(root, SPECDEV_STATUS), blockers, "SpecDev status");
|
|
478
|
-
const statusVersion = specdevStatus?.schema_version;
|
|
479
|
-
const statusV3Detected = statusVersion === 3;
|
|
480
|
-
if (specdevStatus &&
|
|
481
|
-
statusVersion !== 3 &&
|
|
482
|
-
statusVersion !== 4) {
|
|
483
|
-
blockers.push("Unsupported SpecDev status schema_version: " + String(statusVersion));
|
|
484
|
-
}
|
|
485
|
-
if (specdevStatus && statusVersion === 4) {
|
|
486
|
-
blockers.push(...validateSpecdevStatusV4(specdevStatus).map((issue) => "Invalid SpecDev status v4: " + issue));
|
|
487
|
-
}
|
|
488
|
-
const sourceLayout = v2Detected
|
|
489
|
-
? "v2"
|
|
490
|
-
: transitionalDetected
|
|
491
|
-
? "transitional-v3"
|
|
492
|
-
: statusV3Detected
|
|
493
|
-
? "specdev-status-v3"
|
|
494
|
-
: "none";
|
|
495
|
-
const legacyDetected = sourceLayout !== "none" || blockers.length > 0;
|
|
496
|
-
if (!legacyDetected) {
|
|
497
|
-
return {
|
|
498
|
-
target: resolvedTarget,
|
|
499
|
-
legacyDetected,
|
|
500
|
-
sourceLayout,
|
|
501
|
-
actions,
|
|
502
|
-
blockers,
|
|
503
|
-
changes,
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
if (!(await pathExists(root))) {
|
|
507
|
-
blockers.push("Missing legacy state root: " + root);
|
|
508
|
-
return {
|
|
509
|
-
target: resolvedTarget,
|
|
510
|
-
legacyDetected,
|
|
511
|
-
sourceLayout,
|
|
512
|
-
actions,
|
|
513
|
-
blockers,
|
|
514
|
-
changes,
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
if (sourceLayout === "transitional-v3" ||
|
|
518
|
-
sourceLayout === "specdev-status-v3") {
|
|
519
|
-
const oldState = join(root, TRANSITIONAL_DOCS_STATE);
|
|
520
|
-
const newState = join(root, "commands", "docs-sync", "state.json");
|
|
521
|
-
if (await pathExists(oldState)) {
|
|
522
|
-
await readLegacyDocsSyncState(oldState, blockers);
|
|
523
|
-
if (await pathExists(newState)) {
|
|
524
|
-
blockers.push("Docs-sync state destination already exists: " + newState);
|
|
525
|
-
}
|
|
526
|
-
actions.push({
|
|
527
|
-
kind: "move-docs-sync-state",
|
|
528
|
-
from: ".speculo/" + TRANSITIONAL_DOCS_STATE,
|
|
529
|
-
to: ".speculo/commands/docs-sync/state.json",
|
|
530
|
-
detail: "Move the transitional v3 baseline into its command namespace",
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
for (const name of await legacyCommandRunNames(root)) {
|
|
534
|
-
const destination = join(root, "commands", "_legacy", name);
|
|
535
|
-
if (await pathExists(destination)) {
|
|
536
|
-
blockers.push("Legacy command report destination exists: " + destination);
|
|
537
|
-
}
|
|
538
|
-
actions.push({
|
|
539
|
-
kind: "preserve-command-report",
|
|
540
|
-
from: ".speculo/commands/" + name,
|
|
541
|
-
to: ".speculo/commands/_legacy/" + name,
|
|
542
|
-
detail: "Preserve the old report directory without rewriting history",
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
if (statusV3Detected && specdevStatus) {
|
|
546
|
-
await migrateSpecdevStatusV3(specdevStatus, root, blockers);
|
|
547
|
-
actions.push({
|
|
548
|
-
kind: "migrate-specdev-status",
|
|
549
|
-
from: ".speculo/specdev/status.json (schema v3)",
|
|
550
|
-
to: ".speculo/specdev/status.json (schema v4)",
|
|
551
|
-
detail: "Reduce the global index to active and archived changes",
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
return {
|
|
555
|
-
target: resolvedTarget,
|
|
556
|
-
legacyDetected,
|
|
557
|
-
sourceLayout,
|
|
558
|
-
actions,
|
|
559
|
-
blockers,
|
|
560
|
-
changes,
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
const rootEntries = await readdir(root);
|
|
564
|
-
for (const entry of rootEntries) {
|
|
565
|
-
if (!LEGACY_ROOT_ENTRIES.has(entry)) {
|
|
566
|
-
blockers.push("Unknown legacy state entry requires manual handling: " + join(root, entry));
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
for (const workflow of ["dev", "doc", "person"]) {
|
|
570
|
-
await validateLegacyIndex(join(root, workflow + "-status.json"), blockers);
|
|
571
|
-
}
|
|
572
|
-
await readLegacyDocsSyncState(join(root, "dev", "docs-sync-state.json"), blockers);
|
|
573
|
-
for (const workflow of ["dev", "doc", "person"]) {
|
|
574
|
-
changes.push(...await collectActiveChanges(root, workflow, blockers));
|
|
575
|
-
changes.push(...await collectArchivedChanges(root, workflow, blockers));
|
|
576
|
-
}
|
|
577
|
-
const destinations = new Set();
|
|
578
|
-
for (const change of changes) {
|
|
579
|
-
if (destinations.has(change.destinationRelative)) {
|
|
580
|
-
blockers.push("Migration destination collision: " + change.destinationRelative);
|
|
581
|
-
}
|
|
582
|
-
destinations.add(change.destinationRelative);
|
|
583
|
-
actions.push({
|
|
584
|
-
kind: change.archived ? "archive-change" : "preserve-active-change",
|
|
585
|
-
from: relative(installRoot(resolvedTarget), change.sourcePath),
|
|
586
|
-
to: join(".speculo", change.destinationRelative),
|
|
587
|
-
detail: change.sourceWorkflow + "/" + change.originalName,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
if (await pathExists(join(root, ".config"))) {
|
|
591
|
-
actions.push({
|
|
592
|
-
kind: "move-config",
|
|
593
|
-
from: ".speculo/.config",
|
|
594
|
-
to: ".speculo/specdev/.config",
|
|
595
|
-
detail: "Move shared v2 configuration into the SpecDev workflow",
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
if (await pathExists(join(root, "dev", "docs-sync-state.json"))) {
|
|
599
|
-
actions.push({
|
|
600
|
-
kind: "move-docs-sync-state",
|
|
601
|
-
from: ".speculo/dev/docs-sync-state.json",
|
|
602
|
-
to: ".speculo/commands/docs-sync/state.json",
|
|
603
|
-
detail: "Move docs-sync baseline to the global command namespace",
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
for (const legacyPath of [
|
|
607
|
-
"workflows/dev",
|
|
608
|
-
"workflows/doc",
|
|
609
|
-
]) {
|
|
610
|
-
if (await pathExists(join(installRoot(resolvedTarget), legacyPath))) {
|
|
611
|
-
actions.push({
|
|
612
|
-
kind: "remove-legacy-asset",
|
|
613
|
-
from: legacyPath,
|
|
614
|
-
detail: "Remove framework-managed v2 asset after state migration",
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
return {
|
|
619
|
-
target: resolvedTarget,
|
|
620
|
-
legacyDetected,
|
|
621
|
-
sourceLayout,
|
|
622
|
-
actions,
|
|
623
|
-
blockers,
|
|
624
|
-
changes,
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
async function copyDirectoryContents(source, destination) {
|
|
628
|
-
if (!(await pathExists(source)))
|
|
629
|
-
return;
|
|
630
|
-
await mkdir(destination, { recursive: true });
|
|
631
|
-
const entries = await readdir(source, { withFileTypes: true });
|
|
632
|
-
for (const entry of entries) {
|
|
633
|
-
await cp(join(source, entry.name), join(destination, entry.name), {
|
|
634
|
-
recursive: entry.isDirectory(),
|
|
635
|
-
force: true,
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
function migratedStatus(change, destinationName, destinationRelative, now) {
|
|
640
|
-
const status = { ...change.status };
|
|
641
|
-
delete status.category;
|
|
642
|
-
status.schema_version = 1;
|
|
643
|
-
status.workflow = change.sourceWorkflow === "person" ? "person" : "specdev";
|
|
644
|
-
status.name = destinationName;
|
|
645
|
-
status.updated_at = now;
|
|
646
|
-
if (change.archived) {
|
|
647
|
-
status.change_status = "archived";
|
|
648
|
-
status.current_phase = "migration-archive";
|
|
649
|
-
status.archived = true;
|
|
650
|
-
status.archive_path = "speculo/.speculo/" +
|
|
651
|
-
destinationRelative.replaceAll("\\", "/");
|
|
652
|
-
if (change.sourceWorkflow !== "person") {
|
|
653
|
-
status.legacy_source = {
|
|
654
|
-
workflow: change.sourceWorkflow,
|
|
655
|
-
original_name: change.originalName,
|
|
656
|
-
original_status: change.status.change_status ?? "unknown",
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
const history = Array.isArray(status.phase_history)
|
|
660
|
-
? [...status.phase_history]
|
|
661
|
-
: [];
|
|
662
|
-
history.push({
|
|
663
|
-
phase: "migration-archive",
|
|
664
|
-
entered_at: now,
|
|
665
|
-
completed_at: now,
|
|
666
|
-
status: "completed",
|
|
667
|
-
});
|
|
668
|
-
status.phase_history = history;
|
|
669
|
-
}
|
|
670
|
-
return status;
|
|
671
|
-
}
|
|
672
|
-
function migratedDocsSyncState(legacy) {
|
|
673
|
-
const tracked = Array.isArray(legacy.tracked_assets)
|
|
674
|
-
? legacy.tracked_assets
|
|
675
|
-
: Array.isArray(legacy.tracked_docs) ? legacy.tracked_docs : [];
|
|
676
|
-
const synced = Array.isArray(legacy.synced_assets)
|
|
677
|
-
? legacy.synced_assets
|
|
678
|
-
: Array.isArray(legacy.synced_docs) ? legacy.synced_docs : [];
|
|
679
|
-
const lastSyncSha = typeof legacy.last_sync_sha === "string"
|
|
680
|
-
? legacy.last_sync_sha
|
|
681
|
-
: null;
|
|
682
|
-
const previousSyncSha = typeof legacy.previous_sync_sha === "string"
|
|
683
|
-
? legacy.previous_sync_sha
|
|
684
|
-
: null;
|
|
685
|
-
const totalSyncs = typeof legacy.total_syncs === "number" &&
|
|
686
|
-
Number.isInteger(legacy.total_syncs) &&
|
|
687
|
-
legacy.total_syncs >= 0
|
|
688
|
-
? legacy.total_syncs
|
|
689
|
-
: 0;
|
|
690
|
-
return {
|
|
691
|
-
schema_version: 4,
|
|
692
|
-
command: "docs-sync",
|
|
693
|
-
state_path: "speculo/.speculo/commands/docs-sync/state.json",
|
|
694
|
-
baseline: {
|
|
695
|
-
mode: "explicit",
|
|
696
|
-
sha: lastSyncSha,
|
|
697
|
-
},
|
|
698
|
-
last_range: {
|
|
699
|
-
from_sha: previousSyncSha,
|
|
700
|
-
to_sha: lastSyncSha,
|
|
701
|
-
},
|
|
702
|
-
project_targets: [],
|
|
703
|
-
pending_legacy_targets: [...new Set(tracked)],
|
|
704
|
-
scope_revision: 0,
|
|
705
|
-
scope_confirmed_at: null,
|
|
706
|
-
last_sync_run_at: typeof legacy.last_sync_run_at === "string"
|
|
707
|
-
? legacy.last_sync_run_at
|
|
708
|
-
: null,
|
|
709
|
-
total_syncs: totalSyncs,
|
|
710
|
-
synced_assets: synced,
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
function reportMarkdown(plan, now) {
|
|
714
|
-
const lines = [
|
|
715
|
-
"# Speculo Migration Report",
|
|
716
|
-
"",
|
|
717
|
-
"- Generated: " + now,
|
|
718
|
-
"- Target: " + plan.target,
|
|
719
|
-
"- Source layout: " + plan.sourceLayout,
|
|
720
|
-
"- Actions: " + plan.actions.length,
|
|
721
|
-
"",
|
|
722
|
-
"## Actions",
|
|
723
|
-
"",
|
|
724
|
-
];
|
|
725
|
-
for (const action of plan.actions) {
|
|
726
|
-
lines.push("- " + action.kind + ": " +
|
|
727
|
-
(action.from ? action.from : "") +
|
|
728
|
-
(action.to ? " -> " + action.to : "") +
|
|
729
|
-
" — " + action.detail);
|
|
730
|
-
}
|
|
731
|
-
lines.push("", "## Follow-up", "", "Run `speculo init <target>` and select specdev/person as needed.", "Legacy dev/doc active work is historical archive and is not resumed automatically.", "");
|
|
732
|
-
return lines.join("\n");
|
|
733
|
-
}
|
|
734
|
-
async function preserveLegacyCommandEntries(source, destination) {
|
|
735
|
-
if (!(await pathExists(source)))
|
|
736
|
-
return;
|
|
737
|
-
const entries = await readdir(source, { withFileTypes: true });
|
|
738
|
-
for (const entry of entries) {
|
|
739
|
-
if (entry.name === ".config" || entry.name === "_legacy")
|
|
740
|
-
continue;
|
|
741
|
-
await mkdir(destination, { recursive: true });
|
|
742
|
-
await cp(join(source, entry.name), join(destination, entry.name), {
|
|
743
|
-
recursive: entry.isDirectory(),
|
|
744
|
-
force: false,
|
|
745
|
-
errorOnExist: true,
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
await copyDirectoryContents(join(source, "_legacy"), destination);
|
|
749
|
-
}
|
|
750
|
-
async function writeMigrationReport(stage, plan, now) {
|
|
751
|
-
const reportRoot = join(stage, "commands", "migrate");
|
|
752
|
-
await mkdir(reportRoot, { recursive: true });
|
|
753
|
-
const stem = now.slice(0, 10) + "-workspace-migration";
|
|
754
|
-
let reportPath = join(reportRoot, stem + ".md");
|
|
755
|
-
let suffix = 1;
|
|
756
|
-
while (await pathExists(reportPath)) {
|
|
757
|
-
reportPath = join(reportRoot, stem + "-" + String(suffix).padStart(2, "0") + ".md");
|
|
758
|
-
suffix += 1;
|
|
759
|
-
}
|
|
760
|
-
await writeFile(reportPath, reportMarkdown(plan, now));
|
|
761
|
-
}
|
|
762
|
-
async function buildMigratedState(plan, packageRoot, stage) {
|
|
763
|
-
const root = stateRoot(plan.target);
|
|
764
|
-
const now = new Date().toISOString();
|
|
765
|
-
const templateRoot = join(packageRoot, "template");
|
|
766
|
-
if (plan.sourceLayout === "transitional-v3" ||
|
|
767
|
-
plan.sourceLayout === "specdev-status-v3") {
|
|
768
|
-
await cp(root, stage, { recursive: true, force: true });
|
|
769
|
-
const workspaceDestination = join(stage, "workspace.json");
|
|
770
|
-
if (!(await pathExists(workspaceDestination))) {
|
|
771
|
-
await cp(join(templateRoot, ".speculo", "workspace.json"), workspaceDestination, { force: false, errorOnExist: true });
|
|
772
|
-
}
|
|
773
|
-
const commandsRoot = join(stage, "commands");
|
|
774
|
-
const legacyRoot = join(commandsRoot, "_legacy");
|
|
775
|
-
for (const name of await legacyCommandRunNames(stage)) {
|
|
776
|
-
await mkdir(legacyRoot, { recursive: true });
|
|
777
|
-
await rename(join(commandsRoot, name), join(legacyRoot, name));
|
|
778
|
-
}
|
|
779
|
-
const oldState = join(stage, TRANSITIONAL_DOCS_STATE);
|
|
780
|
-
if (await pathExists(oldState)) {
|
|
781
|
-
const legacy = JSON.parse(await readFile(oldState, "utf8"));
|
|
782
|
-
const destination = join(commandsRoot, "docs-sync", "state.json");
|
|
783
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
784
|
-
await writeFile(destination, JSON.stringify(migratedDocsSyncState(legacy), null, 2) + "\n");
|
|
785
|
-
await rm(oldState, { force: true });
|
|
786
|
-
const oldConfigDir = dirname(oldState);
|
|
787
|
-
if ((await readdir(oldConfigDir)).length === 0) {
|
|
788
|
-
await rm(oldConfigDir, { recursive: true, force: true });
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
const statusPath = join(stage, SPECDEV_STATUS);
|
|
792
|
-
if (await pathExists(statusPath)) {
|
|
793
|
-
const status = JSON.parse(await readFile(statusPath, "utf8"));
|
|
794
|
-
if (status.schema_version === 3) {
|
|
795
|
-
const migrationBlockers = [];
|
|
796
|
-
const migrated = await migrateSpecdevStatusV3(status, stage, migrationBlockers);
|
|
797
|
-
if (migrationBlockers.length > 0) {
|
|
798
|
-
throw new Error([
|
|
799
|
-
"SpecDev status changed after migration preview:",
|
|
800
|
-
...migrationBlockers.map((item) => "- " + item),
|
|
801
|
-
].join("\n"));
|
|
802
|
-
}
|
|
803
|
-
await writeFile(statusPath, JSON.stringify(migrated, null, 2) + "\n");
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
await writeMigrationReport(stage, plan, now);
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
await cp(join(templateRoot, ".speculo"), stage, {
|
|
810
|
-
recursive: true,
|
|
811
|
-
force: true,
|
|
812
|
-
});
|
|
813
|
-
await cp(join(templateRoot, "workflows", "specdev", "_state"), join(stage, "specdev"), { recursive: true, force: true });
|
|
814
|
-
await cp(join(templateRoot, "workflows", "person", "_state"), join(stage, "person"), { recursive: true, force: true });
|
|
815
|
-
await preserveLegacyCommandEntries(join(root, "commands"), join(stage, "commands", "_legacy"));
|
|
816
|
-
await copyDirectoryContents(join(root, ".config"), join(stage, "specdev", ".config"));
|
|
817
|
-
const docsSyncSource = join(root, "dev", "docs-sync-state.json");
|
|
818
|
-
if (await pathExists(docsSyncSource)) {
|
|
819
|
-
const legacyDocsSync = JSON.parse(await readFile(docsSyncSource, "utf8"));
|
|
820
|
-
const docsSync = migratedDocsSyncState(legacyDocsSync);
|
|
821
|
-
const destination = join(stage, "commands", "docs-sync", "state.json");
|
|
822
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
823
|
-
await writeFile(destination, JSON.stringify(docsSync, null, 2) + "\n");
|
|
824
|
-
}
|
|
825
|
-
const personActive = [];
|
|
826
|
-
const specdevArchived = [];
|
|
827
|
-
for (const change of plan.changes) {
|
|
828
|
-
const destination = join(stage, change.destinationRelative);
|
|
829
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
830
|
-
await cp(change.sourcePath, destination, {
|
|
831
|
-
recursive: true,
|
|
832
|
-
force: false,
|
|
833
|
-
errorOnExist: true,
|
|
834
|
-
});
|
|
835
|
-
const destinationName = basename(destination);
|
|
836
|
-
const status = migratedStatus(change, destinationName, change.destinationRelative, now);
|
|
837
|
-
await writeFile(join(destination, ".status.json"), JSON.stringify(status, null, 2) + "\n");
|
|
838
|
-
if (change.sourceWorkflow === "person" &&
|
|
839
|
-
!change.archived &&
|
|
840
|
-
status.change_status === "active") {
|
|
841
|
-
personActive.push({
|
|
842
|
-
name: destinationName,
|
|
843
|
-
current_phase: status.current_phase ?? "00-init",
|
|
844
|
-
updated_at: status.updated_at,
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
if (change.sourceWorkflow !== "person" && change.archived) {
|
|
848
|
-
specdevArchived.push(destinationName);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
await writeFile(join(stage, "specdev", "status.json"), JSON.stringify({
|
|
852
|
-
schema_version: 4,
|
|
853
|
-
workflow: "specdev",
|
|
854
|
-
active: [],
|
|
855
|
-
archived: [...new Set(specdevArchived)],
|
|
856
|
-
}, null, 2) + "\n");
|
|
857
|
-
await writeFile(join(stage, "person", "status.json"), JSON.stringify({ schema_version: 1, workflow: "person", active: personActive }, null, 2) + "\n");
|
|
858
|
-
await writeMigrationReport(stage, plan, now);
|
|
859
|
-
}
|
|
860
|
-
async function moveLegacyAssetsToBackup(root, backup) {
|
|
861
|
-
const moved = [];
|
|
862
|
-
for (const relativePath of [
|
|
863
|
-
"workflows/dev",
|
|
864
|
-
"workflows/doc",
|
|
865
|
-
]) {
|
|
866
|
-
const source = join(root, relativePath);
|
|
867
|
-
if (!(await pathExists(source)))
|
|
868
|
-
continue;
|
|
869
|
-
const destination = join(backup, relativePath);
|
|
870
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
871
|
-
await rename(source, destination);
|
|
872
|
-
moved.push({ source, backup: destination });
|
|
873
|
-
}
|
|
874
|
-
return moved;
|
|
875
|
-
}
|
|
876
|
-
async function restoreLegacyAssets(moved) {
|
|
877
|
-
for (const item of [...moved].reverse()) {
|
|
878
|
-
if (!(await pathExists(item.backup)))
|
|
879
|
-
continue;
|
|
880
|
-
await mkdir(dirname(item.source), { recursive: true });
|
|
881
|
-
await rename(item.backup, item.source);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
export async function migrateSpeculo(targetArg = ".", options = {}) {
|
|
885
|
-
const target = resolve(targetArg);
|
|
886
|
-
const packageRoot = resolve(options.packageRoot ?? process.cwd());
|
|
887
|
-
const plan = await planMigration(target);
|
|
888
|
-
if (plan.blockers.length > 0) {
|
|
889
|
-
throw new Error(["Speculo migration blocked:", ...plan.blockers.map((item) => "- " + item)]
|
|
890
|
-
.join("\n"));
|
|
891
|
-
}
|
|
892
|
-
if (!plan.legacyDetected || !options.apply) {
|
|
893
|
-
return {
|
|
894
|
-
target,
|
|
895
|
-
legacyDetected: plan.legacyDetected,
|
|
896
|
-
applied: false,
|
|
897
|
-
actions: plan.actions,
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
const root = installRoot(target);
|
|
901
|
-
const state = stateRoot(target);
|
|
902
|
-
const stage = join(root, ".speculo-migrate-stage");
|
|
903
|
-
const backup = join(root, ".speculo-migrate-backup");
|
|
904
|
-
const assetBackup = join(root, ".speculo-migrate-assets");
|
|
905
|
-
await rm(stage, { recursive: true, force: true });
|
|
906
|
-
await rm(backup, { recursive: true, force: true });
|
|
907
|
-
await rm(assetBackup, { recursive: true, force: true });
|
|
908
|
-
await buildMigratedState(plan, packageRoot, stage);
|
|
909
|
-
let stateMoved = false;
|
|
910
|
-
let newStateInstalled = false;
|
|
911
|
-
let movedAssets = [];
|
|
912
|
-
try {
|
|
913
|
-
movedAssets = await moveLegacyAssetsToBackup(root, assetBackup);
|
|
914
|
-
await rename(state, backup);
|
|
915
|
-
stateMoved = true;
|
|
916
|
-
await rename(stage, state);
|
|
917
|
-
newStateInstalled = true;
|
|
918
|
-
}
|
|
919
|
-
catch (error) {
|
|
920
|
-
if (newStateInstalled && (await pathExists(state))) {
|
|
921
|
-
await rm(state, { recursive: true, force: true });
|
|
922
|
-
}
|
|
923
|
-
if (stateMoved && (await pathExists(backup))) {
|
|
924
|
-
await rename(backup, state);
|
|
925
|
-
}
|
|
926
|
-
await restoreLegacyAssets(movedAssets);
|
|
927
|
-
await rm(stage, { recursive: true, force: true });
|
|
928
|
-
throw error;
|
|
929
|
-
}
|
|
930
|
-
await rm(backup, { recursive: true, force: true });
|
|
931
|
-
await rm(assetBackup, { recursive: true, force: true });
|
|
932
|
-
return {
|
|
933
|
-
target,
|
|
934
|
-
legacyDetected: true,
|
|
935
|
-
applied: true,
|
|
936
|
-
actions: plan.actions,
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
//# sourceMappingURL=migrate.js.map
|