intentdocs 0.11.0 → 0.12.1

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.
@@ -0,0 +1,476 @@
1
+ import {
2
+ listStories
3
+ } from "./chunk-KTGQO3AE.js";
4
+ import {
5
+ classify,
6
+ featureFiles,
7
+ findIntentDir,
8
+ newFeature,
9
+ parseFeature,
10
+ readIntentFolder,
11
+ readSyncBase,
12
+ renderFeature,
13
+ requireConfig,
14
+ slug,
15
+ specOfPushStory,
16
+ specOfStory,
17
+ writeSyncBase
18
+ } from "./chunk-RGFGTIIH.js";
19
+ import {
20
+ choose,
21
+ closePrompt,
22
+ confirm,
23
+ isInteractive
24
+ } from "./chunk-WRRUCCVZ.js";
25
+ import "./chunk-XZE2XP6O.js";
26
+ import "./chunk-UYTVTYNB.js";
27
+
28
+ // src/commands/pull.ts
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
30
+ import { dirname, join, relative } from "path";
31
+ import { parse } from "yaml";
32
+
33
+ // src/lib/pull-files.ts
34
+ import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
35
+ var YAML_OUT = { lineWidth: 0, flowCollectionPadding: false };
36
+ function edit(text, change) {
37
+ const doc = parseDocument(text);
38
+ change(doc);
39
+ return doc.toString(YAML_OUT);
40
+ }
41
+ var seqOf = (doc, key) => {
42
+ const node = doc.get(key, true);
43
+ if (isSeq(node)) return node;
44
+ const seq = doc.createNode([]);
45
+ doc.set(key, seq);
46
+ return seq;
47
+ };
48
+ var itemsOf = (seq) => seq.items.filter((i) => isMap(i));
49
+ function releaseItem(doc, number, title) {
50
+ const releases = seqOf(doc, "releases");
51
+ const found = itemsOf(releases).find((r) => r.get("number") === number);
52
+ if (found) return found;
53
+ const created = doc.createNode({ number, title, stories: [] });
54
+ created.get("stories", true).flow = true;
55
+ const after = itemsOf(releases).findIndex((r) => Number(r.get("number")) > number);
56
+ if (after < 0) releases.items.push(created);
57
+ else releases.items.splice(releases.items.indexOf(itemsOf(releases)[after]), 0, created);
58
+ return created;
59
+ }
60
+ function setReleaseTitle(text, number, title) {
61
+ return edit(text, (doc) => releaseItem(doc, number, title).set("title", title));
62
+ }
63
+ function setActivityTitle(text, id, title) {
64
+ return edit(text, (doc) => {
65
+ const activities = seqOf(doc, "activities");
66
+ const found = itemsOf(activities).find((a) => a.get("id") === id);
67
+ if (found) found.set("title", title);
68
+ else activities.items.push(doc.createNode({ id, title }));
69
+ });
70
+ }
71
+ function ensureActivity(text, id, title) {
72
+ const has = itemsOf(seqOf(parseDocument(text), "activities")).some((a) => a.get("id") === id);
73
+ return has ? text : setActivityTitle(text, id, title);
74
+ }
75
+ function placeStory(text, key, number, releaseTitle) {
76
+ return edit(text, (doc) => {
77
+ const target = releaseItem(doc, number, releaseTitle);
78
+ for (const release of itemsOf(seqOf(doc, "releases"))) {
79
+ const stories = release.get("stories", true);
80
+ if (!isSeq(stories)) continue;
81
+ const at = stories.items.findIndex((s) => isScalar(s) && s.value === key);
82
+ if (at >= 0 && release !== target) stories.items.splice(at, 1);
83
+ }
84
+ const existing = target.get("stories", true);
85
+ let seq;
86
+ if (isSeq(existing)) {
87
+ seq = existing;
88
+ } else {
89
+ seq = doc.createNode([]);
90
+ seq.flow = true;
91
+ target.set("stories", seq);
92
+ }
93
+ if (!seq.items.some((s) => isScalar(s) && s.value === key)) seq.items.push(doc.createNode(key));
94
+ });
95
+ }
96
+ function setState(text, key, fields) {
97
+ return edit(text, (doc) => {
98
+ for (const [field, value] of Object.entries(fields)) if (value !== void 0) doc.setIn(["stories", key, field], value);
99
+ });
100
+ }
101
+ function planVersions(names, key) {
102
+ const re = new RegExp(`^${key.replace(/[-]/g, "\\-")}\\.v(\\d+)\\.md$`);
103
+ return names.map((n) => re.exec(n)?.[1]).filter((v) => !!v).map(Number).sort((a, b) => a - b);
104
+ }
105
+ function planBody(text) {
106
+ return text.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
107
+ }
108
+ function renderPlan(key, version, plan, labelOf, today) {
109
+ const meta = { story: key, version, created: today };
110
+ if (version > 1) meta.supersedes = version - 1;
111
+ if (plan.summary) meta.summary = plan.summary;
112
+ const steps = plan.steps.map((step, i) => {
113
+ const title = step.title ?? step.text.split("\n")[0] ?? `Step ${i + 1}`;
114
+ const lines = [`## ${i + 1}. ${title}`];
115
+ if (step.why) lines.push(`**Why:** ${step.why}`);
116
+ const criteria = step.criteria.map((id) => labelOf(id) ?? id);
117
+ if (criteria.length) lines.push(`**Criteria:** ${criteria.join(", ")}`);
118
+ if (step.files.length) lines.push(`**Files:** ${step.files.join(", ")}`);
119
+ if (step.tests.length) lines.push(`**Tests:** ${step.tests.join(", ")}`);
120
+ if (step.needs_you) lines.push(`**Needs you:** ${step.needs_you}`);
121
+ const notes = step.title ? step.agent_notes : step.text;
122
+ if (notes) lines.push("", notes.trim());
123
+ return lines.join("\n");
124
+ });
125
+ return `---
126
+ ${stringify(meta, YAML_OUT)}---
127
+
128
+ ${steps.join("\n\n")}
129
+ `;
130
+ }
131
+
132
+ // src/lib/text-diff.ts
133
+ function ops(a, b) {
134
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
135
+ for (let i2 = a.length - 1; i2 >= 0; i2--) {
136
+ for (let j2 = b.length - 1; j2 >= 0; j2--) {
137
+ lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
138
+ }
139
+ }
140
+ const out = [];
141
+ let i = 0;
142
+ let j = 0;
143
+ while (i < a.length || j < b.length) {
144
+ if (i < a.length && j < b.length && a[i] === b[j]) {
145
+ out.push({ kind: " ", line: a[i], a: i++, b: j++ });
146
+ } else if (i < a.length && (j >= b.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
147
+ out.push({ kind: "-", line: a[i], a: i++, b: j });
148
+ } else {
149
+ out.push({ kind: "+", line: b[j], a: i, b: j++ });
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ function unifiedDiff(before, after, label, context = 3) {
155
+ if (before === after) return "";
156
+ const all = ops(before.split("\n"), after.split("\n"));
157
+ const changed = all.map((o, n) => o.kind === " " ? -1 : n).filter((n) => n >= 0);
158
+ const hunks = [];
159
+ for (const n of changed) {
160
+ const start = Math.max(0, n - context);
161
+ const end = Math.min(all.length - 1, n + context);
162
+ const last = hunks[hunks.length - 1];
163
+ if (last && start <= last[1] + 1) last[1] = end;
164
+ else hunks.push([start, end]);
165
+ }
166
+ const lines = [`--- ${label} (in the repo)`, `+++ ${label} (from intentdocs)`];
167
+ for (const [start, end] of hunks) {
168
+ const slice = all.slice(start, end + 1);
169
+ const aLen = slice.filter((o) => o.kind !== "+").length;
170
+ const bLen = slice.filter((o) => o.kind !== "-").length;
171
+ lines.push(`@@ -${slice[0].a + 1},${aLen} +${slice[0].b + 1},${bLen} @@`);
172
+ for (const o of slice) lines.push(`${o.kind}${o.line}`);
173
+ }
174
+ return lines.join("\n");
175
+ }
176
+
177
+ // src/commands/pull.ts
178
+ var Files = class _Files {
179
+ constructor(read) {
180
+ this.read = read;
181
+ }
182
+ changed = /* @__PURE__ */ new Map();
183
+ get(path) {
184
+ return this.changed.has(path) ? this.changed.get(path) : this.read(path);
185
+ }
186
+ set(path, text) {
187
+ this.changed.set(path, text);
188
+ }
189
+ clone() {
190
+ const copy = new _Files(this.read);
191
+ for (const [p, t] of this.changed) copy.set(p, t);
192
+ return copy;
193
+ }
194
+ /** Paths whose text differs from the file on disk. */
195
+ edits() {
196
+ return [...this.changed].filter(([p, t]) => this.read(p) !== t);
197
+ }
198
+ };
199
+ var PullError = class extends Error {
200
+ constructor(message, details = []) {
201
+ super(message);
202
+ this.details = details;
203
+ this.name = "PullError";
204
+ }
205
+ };
206
+ var KIND_LABEL = {
207
+ web: "From intentdocs",
208
+ clash: "Changed in intentdocs AND in the repo since the last sync",
209
+ unrecorded: "Differs from intentdocs, with no sync record to say which side changed"
210
+ };
211
+ var STATUSES = /* @__PURE__ */ new Set(["todo", "in_progress", "needs_rework", "done"]);
212
+ function planUnits(deps, response, base, nextBase, outcome) {
213
+ const intentDir = findIntentDir(deps.cwd);
214
+ const read = readIntentFolder(deps.cwd);
215
+ if (!read.ok) throw new PullError(`intent/ needs fixing before a pull (${read.dir}):`, read.errors);
216
+ const storymapPath = join(intentDir, "storymap.yaml");
217
+ const statePath = join(intentDir, "state.yaml");
218
+ const plansDir = join(intentDir, "plans");
219
+ const today = deps.now().toISOString().slice(0, 10);
220
+ const files = featureFiles(intentDir);
221
+ const local = new Map(read.payload.stories.map((s) => [s.key, s]));
222
+ const storymap = parse(deps.readFile(storymapPath) ?? "");
223
+ const state = parse(deps.readFile(statePath) ?? "") ?? {};
224
+ const remoteReleases = new Map((response.releases ?? []).map((r) => [r.number, r.title]));
225
+ const releaseTitle = (n) => remoteReleases.get(n) ?? storymap.releases?.find((r) => r.number === n)?.title ?? `Release ${n}`;
226
+ const units = [];
227
+ for (const [number, title] of remoteReleases) {
228
+ const mine = storymap.releases?.find((r) => r.number === number)?.title;
229
+ const was = base.releases[String(number)];
230
+ const kind = mine === void 0 && was === void 0 ? "web" : classify(mine, title, was);
231
+ if (kind === null) nextBase.releases[String(number)] = title;
232
+ else if (kind !== "local") {
233
+ units.push({
234
+ title: mine === void 0 ? `New release ${number}: ${title}` : `Release ${number} renamed to "${title}"`,
235
+ kind,
236
+ apply: (f) => f.set(storymapPath, setReleaseTitle(f.get(storymapPath), number, title)),
237
+ record: (b) => b.releases[String(number)] = title
238
+ });
239
+ }
240
+ }
241
+ for (const activity of response.activities ?? []) {
242
+ if (!activity.ref?.startsWith("nikos:")) continue;
243
+ const id = activity.ref.slice("nikos:".length);
244
+ const mine = storymap.activities?.find((a) => a.id === id)?.title;
245
+ if (mine === void 0) continue;
246
+ const kind = classify(mine, activity.title, base.activities[id]);
247
+ if (kind === null) nextBase.activities[id] = activity.title;
248
+ else if (kind !== "local") {
249
+ units.push({
250
+ title: `Activity ${id} renamed to "${activity.title}"`,
251
+ kind,
252
+ apply: (f) => f.set(storymapPath, setActivityTitle(f.get(storymapPath), id, activity.title)),
253
+ record: (b) => b.activities[id] = activity.title
254
+ });
255
+ }
256
+ }
257
+ const live = response.stories.filter((s) => s.key);
258
+ outcome.cancelled = live.filter((s) => s.cancelled_at && files.has(s.key)).map((s) => s.key);
259
+ for (const story of live.filter((s) => !s.cancelled_at)) {
260
+ const key = story.key;
261
+ const theirs = specOfStory(story, storymap.activities ?? []);
262
+ const mine = local.get(key);
263
+ const path = files.get(key);
264
+ if (mine && path) {
265
+ const kind = classify(specOfPushStory(mine), theirs, base.stories[key]);
266
+ if (kind === null) nextBase.stories[key] = theirs;
267
+ else if (kind === "local") outcome.localOnly.push(key);
268
+ else units.push(storyUnit(key, path, mine, theirs, story, kind));
269
+ } else if (!mine) {
270
+ units.push(newStoryUnit(key, theirs, story));
271
+ }
272
+ }
273
+ function placeIn(f, key, theirs, story) {
274
+ let map = f.get(storymapPath);
275
+ map = ensureActivity(map, theirs.activity, story.activity);
276
+ map = placeStory(map, key, theirs.release, releaseTitle(theirs.release));
277
+ f.set(storymapPath, map);
278
+ }
279
+ function storyUnit(key, path, mine, theirs, story, kind) {
280
+ return {
281
+ title: `${key} ${theirs.title}`,
282
+ kind,
283
+ apply: (f) => {
284
+ f.set(path, renderFeature(f.get(path), theirs));
285
+ if (mine.release !== theirs.release || mine.activity_id !== theirs.activity) placeIn(f, key, theirs, story);
286
+ },
287
+ record: (b) => b.stories[key] = theirs
288
+ };
289
+ }
290
+ function newStoryUnit(key, theirs, story) {
291
+ const dir = join(intentDir, "features", theirs.activity);
292
+ const name = slug(theirs.title);
293
+ const path = deps.readFile(join(dir, `${name}.feature`)) === null ? join(dir, `${name}.feature`) : join(dir, `${name}-${key.toLowerCase()}.feature`);
294
+ const status = STATUSES.has(story.status) ? story.status : "todo";
295
+ return {
296
+ title: `New story ${key} ${theirs.title}`,
297
+ kind: "web",
298
+ apply: (f) => {
299
+ f.set(path, newFeature(key, theirs));
300
+ placeIn(f, key, theirs, story);
301
+ f.set(statePath, setState(f.get(statePath), key, { status, updated: today }));
302
+ if (story.agent_plan) writePlan(f, key, path, story, story.agent_plan);
303
+ },
304
+ record: (b) => {
305
+ b.stories[key] = theirs;
306
+ b.statuses[key] = status;
307
+ if (story.agent_plan) b.plans[key] = story.agent_plan.version;
308
+ }
309
+ };
310
+ }
311
+ function writePlan(f, key, featurePath, story, plan) {
312
+ const existing = planVersions(deps.listDir(plansDir), key);
313
+ let next = (existing[existing.length - 1] ?? 0) + 1;
314
+ while (f.get(join(plansDir, `${key}.v${next}.md`)) !== null) next++;
315
+ const criteria = parseFeature(f.get(featurePath) ?? "")?.criteria ?? [];
316
+ const textOf = new Map(story.acceptance_criteria.map((c) => [c.id, c.text.trim()]));
317
+ const labelOf = (id) => criteria.find((c) => c.text.trim() === textOf.get(id))?.label || void 0;
318
+ f.set(join(plansDir, `${key}.v${next}.md`), renderPlan(key, next, plan, labelOf, today));
319
+ f.set(statePath, setState(f.get(statePath), key, { plan_version: next }));
320
+ }
321
+ const statusChanges = [];
322
+ const notMoved = [];
323
+ for (const s of live.filter((s2) => !s2.cancelled_at && STATUSES.has(s2.status) && state.stories?.[s2.key])) {
324
+ const key = s.key;
325
+ const mine = state.stories[key].status ?? "todo";
326
+ const was = base.statuses[key];
327
+ if (mine === s.status) nextBase.statuses[key] = s.status;
328
+ else if (was === s.status || was === void 0 && s.status === "todo") {
329
+ nextBase.statuses[key] = s.status;
330
+ notMoved.push(`${key} (${mine} here)`);
331
+ } else statusChanges.push({ key, from: mine, to: s.status });
332
+ }
333
+ outcome.statusNotMoved = notMoved;
334
+ if (statusChanges.length) {
335
+ units.push({
336
+ title: `Statuses: ${statusChanges.map((c) => `${c.key} ${c.from} \u2192 ${c.to}`).join(", ")}`,
337
+ kind: "web",
338
+ apply: (f) => {
339
+ let text = f.get(statePath);
340
+ for (const c of statusChanges) text = setState(text, c.key, { status: c.to, updated: today });
341
+ f.set(statePath, text);
342
+ },
343
+ record: (b) => {
344
+ for (const c of statusChanges) b.statuses[c.key] = c.to;
345
+ }
346
+ });
347
+ }
348
+ for (const story of live.filter((s) => !s.cancelled_at && s.agent_plan && files.has(s.key) && local.has(s.key))) {
349
+ const key = story.key;
350
+ const plan = story.agent_plan;
351
+ if ((base.plans[key] ?? 0) >= plan.version) continue;
352
+ const versions = planVersions(deps.listDir(plansDir), key);
353
+ const latest = versions[versions.length - 1];
354
+ const path = files.get(key);
355
+ const preview = new Files(deps.readFile);
356
+ writePlan(preview, key, path, story, plan);
357
+ const drafted = preview.edits().find(([p]) => p.startsWith(plansDir))?.[1] ?? "";
358
+ const current = latest ? deps.readFile(join(plansDir, `${key}.v${latest}.md`)) : null;
359
+ if (current && planBody(current) === planBody(drafted)) {
360
+ nextBase.plans[key] = plan.version;
361
+ continue;
362
+ }
363
+ units.push({
364
+ title: `Plan for ${key}: intentdocs plan v${plan.version} as ${key}.v${(latest ?? 0) + 1}.md`,
365
+ kind: "web",
366
+ apply: (f) => writePlan(f, key, path, story, plan),
367
+ record: (b) => b.plans[key] = plan.version
368
+ });
369
+ }
370
+ return units;
371
+ }
372
+ async function runPull(deps) {
373
+ const response = await deps.listStories();
374
+ const base = deps.readSyncBase();
375
+ const nextBase = structuredClone(base);
376
+ const outcome = { units: [], written: [], localOnly: [], cancelled: [], statusNotMoved: [] };
377
+ outcome.units = planUnits(deps, response, base, nextBase, outcome);
378
+ const disk = new Files(deps.readFile);
379
+ for (const unit of outcome.units) {
380
+ deps.log(`
381
+ ${KIND_LABEL[unit.kind]}: ${unit.title}`);
382
+ const preview = disk.clone();
383
+ unit.apply(preview);
384
+ for (const [path, after] of preview.edits()) {
385
+ const rel = relative(deps.cwd, path);
386
+ const before = deps.readFile(path);
387
+ deps.log(before === null ? `+++ ${rel} (new file)
388
+ ${after.trimEnd().split("\n").map((l) => `+${l}`).join("\n")}` : unifiedDiff(before, after, rel));
389
+ }
390
+ }
391
+ if (outcome.localOnly.length) deps.log(`
392
+ Changed only in the repo, so nothing to pull: ${outcome.localOnly.join(", ")}. \`intentdocs push\` sends them.`);
393
+ if (outcome.statusNotMoved.length)
394
+ deps.log(`
395
+ intentdocs hasn't moved these since the last sync, so their status here is kept: ${outcome.statusNotMoved.join(", ")}.`);
396
+ if (outcome.cancelled.length) deps.log(`Cancelled in intentdocs, left as they are here: ${outcome.cancelled.join(", ")}.`);
397
+ const safe = outcome.units.filter((u) => u.kind === "web");
398
+ const risky = outcome.units.filter((u) => u.kind !== "web");
399
+ const apply = /* @__PURE__ */ new Set();
400
+ if (outcome.units.length === 0) {
401
+ if (!outcome.localOnly.length && !outcome.statusNotMoved.length) deps.log("\nThe repo already matches intentdocs.");
402
+ } else if (deps.yes) {
403
+ for (const u of safe) apply.add(u);
404
+ if (risky.length) deps.log(`
405
+ --yes doesn't settle: ${risky.map((u) => u.title).join("; ")}. Run \`intentdocs pull\` in a terminal to choose.`);
406
+ } else if (!deps.interactive) {
407
+ deps.log("\nNothing written. Run `intentdocs pull` in a terminal to choose, or `intentdocs pull --yes` once you've agreed to the changes above.");
408
+ } else {
409
+ if (safe.length) {
410
+ const pick = await deps.choose(`Write ${safe.length === 1 ? "this change" : `these ${safe.length} changes`} from intentdocs?`, [
411
+ "Write nothing",
412
+ `Write ${safe.length === 1 ? "it" : `all ${safe.length}`}`,
413
+ "Choose one by one"
414
+ ]);
415
+ if (pick === 1) for (const u of safe) apply.add(u);
416
+ if (pick === 2) {
417
+ for (const u of safe) if (await deps.confirm(`Write: ${u.title}?`)) apply.add(u);
418
+ }
419
+ }
420
+ for (const u of risky) {
421
+ if (await deps.confirm(`${u.title}: replace the repo's version with intentdocs' (your local edits to it are lost)?`)) apply.add(u);
422
+ }
423
+ }
424
+ const result = new Files(deps.readFile);
425
+ for (const unit of outcome.units.filter((u) => apply.has(u))) {
426
+ unit.apply(result);
427
+ unit.record(nextBase);
428
+ }
429
+ for (const [path, text] of result.edits()) {
430
+ deps.writeFile(path, text);
431
+ outcome.written.push(relative(deps.cwd, path));
432
+ }
433
+ deps.writeSyncBase(nextBase);
434
+ if (outcome.written.length) deps.log(`
435
+ \u2713 Wrote ${outcome.written.join(", ")}`);
436
+ else if (outcome.units.length) deps.log("\nNothing was written.");
437
+ return outcome;
438
+ }
439
+ async function pull(opts = {}) {
440
+ const config = requireConfig();
441
+ try {
442
+ await runPull({
443
+ cwd: process.cwd(),
444
+ listStories: () => listStories(config),
445
+ readSyncBase,
446
+ writeSyncBase,
447
+ readFile: (p) => existsSync(p) ? readFileSync(p, "utf-8") : null,
448
+ writeFile: (p, t) => {
449
+ mkdirSync(dirname(p), { recursive: true });
450
+ writeFileSync(p, t);
451
+ },
452
+ listDir: (p) => existsSync(p) ? readdirSync(p) : [],
453
+ interactive: isInteractive(),
454
+ yes: opts.yes ?? false,
455
+ choose,
456
+ confirm,
457
+ log: (line) => console.log(line),
458
+ now: () => /* @__PURE__ */ new Date()
459
+ });
460
+ } catch (err) {
461
+ if (err instanceof PullError) {
462
+ console.error(`\u2717 ${err.message}`);
463
+ for (const d of err.details) console.error(` - ${d}`);
464
+ } else {
465
+ console.error(`\u2717 Pull failed: ${err.message}`);
466
+ }
467
+ process.exitCode = 1;
468
+ } finally {
469
+ closePrompt();
470
+ }
471
+ }
472
+ export {
473
+ PullError,
474
+ pull,
475
+ runPull
476
+ };
@@ -0,0 +1,220 @@
1
+ import {
2
+ login
3
+ } from "./chunk-64I7RLP3.js";
4
+ import {
5
+ checkoutMismatch
6
+ } from "./chunk-KTGQO3AE.js";
7
+ import {
8
+ checkoutConfig,
9
+ readConfig,
10
+ readIntentFolder,
11
+ readSyncBase,
12
+ specOfPushStory,
13
+ writeConfig,
14
+ writeSyncBase
15
+ } from "./chunk-RGFGTIIH.js";
16
+ import {
17
+ choose,
18
+ closePrompt,
19
+ isInteractive
20
+ } from "./chunk-WRRUCCVZ.js";
21
+ import {
22
+ authenticatedFetch,
23
+ getAuth
24
+ } from "./chunk-XZE2XP6O.js";
25
+ import "./chunk-UYTVTYNB.js";
26
+
27
+ // src/commands/push.ts
28
+ import { exec } from "child_process";
29
+
30
+ // src/lib/push-api.ts
31
+ var PUSH_ENDPOINTS = {
32
+ listWorkspaces: "/api/cli/workspaces",
33
+ createPersonalWorkspace: "/api/cli/workspaces/personal",
34
+ createProject: "/api/cli/push",
35
+ updateProject: (projectId) => `/api/cli/push/${encodeURIComponent(projectId)}`,
36
+ projectMaps: (projectId) => `/api/cli/projects/${encodeURIComponent(projectId)}/maps`
37
+ };
38
+ var PushApiError = class extends Error {
39
+ constructor(message, status, code = null, problems = [], projectId = null) {
40
+ super(message);
41
+ this.status = status;
42
+ this.code = code;
43
+ this.problems = problems;
44
+ this.projectId = projectId;
45
+ this.name = "PushApiError";
46
+ }
47
+ };
48
+ async function call(fetcher, path, init, what) {
49
+ const res = await fetcher(path, init);
50
+ if (!res.ok) {
51
+ const body = await res.json().catch(() => ({}));
52
+ const nested = typeof body.error === "object" && body.error ? body.error : null;
53
+ const said = body.message ?? (nested ? nested.hint ?? nested.message : void 0);
54
+ throw new PushApiError(
55
+ said ?? `Couldn't ${what} (the server returned ${res.status}).`,
56
+ res.status,
57
+ typeof body.error === "string" ? body.error : null,
58
+ Array.isArray(body.problems) ? body.problems : [],
59
+ body.project_id ?? null
60
+ );
61
+ }
62
+ return await res.json();
63
+ }
64
+ var json = (method, body) => body === void 0 ? { method } : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
65
+ function pushApi(fetcher = authenticatedFetch) {
66
+ return {
67
+ listWorkspaces: () => call(fetcher, PUSH_ENDPOINTS.listWorkspaces, json("GET"), "list your workspaces").then((r) => r.workspaces),
68
+ createPersonalWorkspace: () => call(fetcher, PUSH_ENDPOINTS.createPersonalWorkspace, json("POST"), "open your personal workspace").then(
69
+ (r) => r.workspace
70
+ ),
71
+ createProject: (workspaceId, payload) => call(fetcher, PUSH_ENDPOINTS.createProject, json("POST", { workspace_id: workspaceId, intent: payload }), "create the project"),
72
+ updateProject: (projectId, mapId, payload) => call(fetcher, PUSH_ENDPOINTS.updateProject(projectId), json("PUT", { map_id: mapId, intent: payload }), "update the project"),
73
+ /** The project's story maps, newest first. */
74
+ projectMaps: (projectId) => call(fetcher, PUSH_ENDPOINTS.projectMaps(projectId), json("GET"), "find the project's story map").then(
75
+ (r) => r.maps
76
+ )
77
+ };
78
+ }
79
+
80
+ // src/commands/push.ts
81
+ var PushError = class extends Error {
82
+ constructor(message, details = []) {
83
+ super(message);
84
+ this.details = details;
85
+ this.name = "PushError";
86
+ }
87
+ };
88
+ function summary(p) {
89
+ const criteria = p.stories.reduce((n, s) => n + s.criteria.length, 0);
90
+ return `${p.stories.length} stories, ${criteria} criteria, ${p.releases.length} releases, ${p.plans.length} plans, ${p.decisions.length} decisions`;
91
+ }
92
+ async function pickWorkspace(deps, auth) {
93
+ const workspaces = await deps.api.listWorkspaces();
94
+ if (auth.binding) {
95
+ const { orgId, workspaceName } = auth.binding;
96
+ return workspaces.find((w) => w.orgId === orgId) ?? { id: orgId, orgId, name: workspaceName ?? null, personal: false };
97
+ }
98
+ if (workspaces.length === 0) return deps.api.createPersonalWorkspace();
99
+ if (workspaces.length === 1) return workspaces[0];
100
+ const personal = Math.max(0, workspaces.findIndex((w) => w.personal));
101
+ return workspaces[await deps.chooseWorkspace(workspaces, personal)] ?? workspaces[personal];
102
+ }
103
+ function pushNotes(result) {
104
+ const notes = [];
105
+ const statuses = result.ignored?.statuses ?? 0;
106
+ if (statuses > 0) {
107
+ notes.push(
108
+ `${statuses} story ${statuses === 1 ? "status was" : "statuses were"} not applied: intentdocs sets status itself. New stories start as To do, existing ones keep theirs, and they move through build and acceptance in intentdocs.`
109
+ );
110
+ }
111
+ const missing = result.summary?.stories?.missing ?? [];
112
+ if (missing.length) notes.push(`In intentdocs but not in intent/: ${missing.join(", ")}. Push never deletes, so they stay.`);
113
+ if (result.not_stored?.length) notes.push(`Not stored by intentdocs yet: ${result.not_stored.join(", ")}. They stay in intent/.`);
114
+ return notes;
115
+ }
116
+ async function runPush(deps) {
117
+ const read = readIntentFolder(deps.cwd);
118
+ if (!read.ok) throw new PushError(`intent/ can't be pushed yet (${read.dir}):`, read.errors);
119
+ const payload = read.payload;
120
+ let auth = deps.getAuth();
121
+ if (!auth) {
122
+ deps.log("You need to log in first. Opening the browser\u2026");
123
+ await deps.login();
124
+ auth = deps.getAuth();
125
+ if (!auth) throw new PushError("Login didn't complete, so nothing was pushed.");
126
+ }
127
+ const existing = deps.readConfig();
128
+ const linked = existing?.projectId && existing.mapId ? { projectId: existing.projectId, mapId: existing.mapId } : null;
129
+ if (existing) {
130
+ const mismatch = checkoutMismatch(existing, auth.binding);
131
+ if (mismatch) throw new PushError(mismatch);
132
+ }
133
+ deps.log(`Pushing "${payload.project.name}": ${summary(payload)}`);
134
+ let workspace = null;
135
+ let result;
136
+ let created = !linked;
137
+ if (linked) {
138
+ result = await deps.api.updateProject(linked.projectId, linked.mapId, payload);
139
+ } else {
140
+ workspace = await pickWorkspace(deps, auth);
141
+ try {
142
+ result = await deps.api.createProject(workspace.id, payload);
143
+ } catch (err) {
144
+ if (!(err instanceof PushApiError && err.code === "ALREADY_PUSHED" && err.projectId)) throw err;
145
+ const [map] = await deps.api.projectMaps(err.projectId);
146
+ if (!map) throw err;
147
+ deps.log(`"${payload.project.name}" was pushed before, so this updates that project.`);
148
+ result = await deps.api.updateProject(err.projectId, map.id, payload);
149
+ created = false;
150
+ }
151
+ }
152
+ deps.writeConfig({
153
+ ...existing,
154
+ ...checkoutConfig({ apiBase: auth.apiBase, project: result.project, map: result.map, appUrl: existing?.appUrl }),
155
+ lastPushed: deps.now().toISOString()
156
+ });
157
+ const base = deps.readSyncBase();
158
+ for (const story of payload.stories) {
159
+ base.stories[story.key] = specOfPushStory(story);
160
+ base.activities[story.activity_id] = story.activity;
161
+ }
162
+ for (const release of payload.releases) base.releases[String(release.number)] = release.title;
163
+ for (const key of result.summary?.stories?.created ?? []) base.statuses[key] = "todo";
164
+ deps.writeSyncBase(base);
165
+ deps.openBrowser(result.url);
166
+ return { result, created, workspace };
167
+ }
168
+ function openBrowser(url) {
169
+ const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
170
+ exec(cmd);
171
+ }
172
+ async function push() {
173
+ const deps = {
174
+ cwd: process.cwd(),
175
+ api: pushApi(),
176
+ getAuth,
177
+ login,
178
+ readConfig,
179
+ writeConfig,
180
+ readSyncBase,
181
+ writeSyncBase,
182
+ openBrowser,
183
+ chooseWorkspace: async (workspaces, defaultIndex) => {
184
+ if (!isInteractive()) return defaultIndex;
185
+ const order = [defaultIndex, ...workspaces.map((_, i) => i).filter((i) => i !== defaultIndex)];
186
+ const labels = order.map((i, n) => `${workspaces[i].personal ? "Personal" : workspaces[i].name ?? "Workspace"}${n === 0 ? " (default)" : ""}`);
187
+ return order[await choose("Which workspace should this project go in?", labels)];
188
+ },
189
+ log: (line) => console.log(line),
190
+ now: () => /* @__PURE__ */ new Date()
191
+ };
192
+ try {
193
+ const { result, created } = await runPush(deps);
194
+ console.log(`
195
+ \u2713 ${created ? "Created" : "Updated"} "${result.map.title}"${result.project.workspace ? ` in ${result.project.workspace.name ?? "your workspace"}` : ""}`);
196
+ for (const note of pushNotes(result)) console.log(` ${note}`);
197
+ console.log(` Linked this repo in .intent/config.json \u2014 run \`npx intentdocs push\` again to sync.`);
198
+ console.log(` Opening ${result.url}
199
+ `);
200
+ } catch (err) {
201
+ if (err instanceof PushError) {
202
+ console.error(`\u2717 ${err.message}`);
203
+ for (const d of err.details) console.error(` - ${d}`);
204
+ } else if (err instanceof PushApiError) {
205
+ console.error(`\u2717 ${err.message}`);
206
+ for (const p of err.problems) console.error(` - ${p}`);
207
+ } else {
208
+ console.error(`\u2717 Push failed: ${err.message}`);
209
+ }
210
+ process.exitCode = 1;
211
+ } finally {
212
+ closePrompt();
213
+ }
214
+ }
215
+ export {
216
+ PushError,
217
+ push,
218
+ pushNotes,
219
+ runPush
220
+ };