deploylog 0.3.0 → 0.4.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 +109 -30
- package/dist/api.d.ts +59 -1
- package/dist/api.js +43 -2
- package/dist/editor.d.ts +23 -0
- package/dist/editor.js +46 -0
- package/dist/entry-commands.d.ts +111 -0
- package/dist/entry-commands.js +157 -0
- package/dist/index.js +479 -108
- package/dist/init.d.ts +38 -0
- package/dist/init.js +86 -0
- package/dist/open.d.ts +33 -0
- package/dist/open.js +51 -0
- package/dist/project-config.d.ts +15 -5
- package/dist/project-config.js +43 -7
- package/dist/push.d.ts +71 -0
- package/dist/push.js +149 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,54 +38,94 @@ Credentials are stored with [`conf`](https://github.com/sindresorhus/conf) in yo
|
|
|
38
38
|
## Quick start
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
#
|
|
42
|
-
deploylog
|
|
41
|
+
# Wire this repo to a project (writes .deploylog.yml)
|
|
42
|
+
deploylog init
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
deploylog push
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
--publish
|
|
44
|
+
# Draft an entry from your commits, rewritten by AI
|
|
45
|
+
deploylog push --from-git --ai-summarize
|
|
46
|
+
|
|
47
|
+
# Review it, then ship it
|
|
48
|
+
deploylog list --drafts
|
|
49
|
+
deploylog view dark-mode
|
|
50
|
+
deploylog publish dark-mode
|
|
52
51
|
```
|
|
53
52
|
|
|
53
|
+
The full lifecycle lives in the terminal: create, list, view, edit, publish, unpublish, delete. No dashboard detour.
|
|
54
|
+
|
|
54
55
|
## Project config
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
`deploylog init` writes a `.deploylog.yml` at your repo root so you don't have to pass `--project` every time:
|
|
57
58
|
|
|
58
59
|
```yaml
|
|
59
60
|
project: my-app
|
|
61
|
+
default_type: feature # optional
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Referencing entries
|
|
65
|
+
|
|
66
|
+
Entry commands accept a **slug or an id** (`deploylog publish dark-mode`, `deploylog publish 3f2b8a1c-...`). Slugs are matched against the 50 most recent entries; older entries need the id (`deploylog list` shows both). Note that editing a draft's title can change its slug, so scripts should prefer ids.
|
|
67
|
+
|
|
68
|
+
## Machine-readable output
|
|
69
|
+
|
|
70
|
+
Every data command takes `--json`: raw JSON on stdout, errors as `{"error":{"code","message"}}` on stderr, and no interactive prompts, ever. Built for CI and AI agents.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
deploylog list --drafts --json | jq -r '.[0].id'
|
|
60
74
|
```
|
|
61
75
|
|
|
62
76
|
## Commands
|
|
63
77
|
|
|
64
|
-
### `deploylog login`
|
|
78
|
+
### `deploylog login` / `deploylog logout`
|
|
65
79
|
|
|
66
|
-
Authenticate with an API key.
|
|
80
|
+
Authenticate with an API key (create one at [deploylog.dev/dashboard/api-keys](https://deploylog.dev/dashboard/api-keys)).
|
|
67
81
|
|
|
68
82
|
```
|
|
69
83
|
--key <key> API key (starts with dk_)
|
|
70
84
|
--api-url <url> API base URL (default: https://deploylog.dev)
|
|
71
85
|
```
|
|
72
86
|
|
|
73
|
-
### `deploylog
|
|
87
|
+
### `deploylog init`
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
Scaffold `.deploylog.yml` in the current directory. Picks the project interactively, or takes `--project`.
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
```
|
|
92
|
+
-p, --project <slug> Project slug
|
|
93
|
+
-T, --type <type> Default entry type for pushes from this repo
|
|
94
|
+
--force Overwrite an existing .deploylog.yml
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `deploylog projects` (alias: `proj`)
|
|
78
98
|
|
|
79
99
|
List projects in your organization.
|
|
80
100
|
|
|
81
|
-
### `deploylog
|
|
101
|
+
### `deploylog projects create <name>`
|
|
102
|
+
|
|
103
|
+
Create a project. The slug is generated from the name.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
--url <url> Project website URL
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `deploylog whoami`
|
|
110
|
+
|
|
111
|
+
Show the authenticated org, plan, API key (name, prefix, permissions), and AI usage this month.
|
|
112
|
+
|
|
113
|
+
### `deploylog list` (alias: `ls`)
|
|
82
114
|
|
|
83
|
-
List recent entries for a project.
|
|
115
|
+
List recent entries for a project. Prints each entry's slug and id.
|
|
84
116
|
|
|
85
117
|
```
|
|
86
118
|
-p, --project <slug> Project slug (or set in .deploylog.yml)
|
|
119
|
+
--drafts Only drafts
|
|
120
|
+
--published Only published entries
|
|
121
|
+
-T, --type <type> Filter by entry type
|
|
122
|
+
-n, --limit <n> Max entries (1-50)
|
|
87
123
|
```
|
|
88
124
|
|
|
125
|
+
### `deploylog view <entry>` (alias: `show`)
|
|
126
|
+
|
|
127
|
+
Show a full entry, including its Markdown body.
|
|
128
|
+
|
|
89
129
|
### `deploylog push`
|
|
90
130
|
|
|
91
131
|
Create a new changelog entry.
|
|
@@ -103,31 +143,70 @@ Create a new changelog entry.
|
|
|
103
143
|
-y, --yes Skip interactive confirmation for AI-generated content
|
|
104
144
|
```
|
|
105
145
|
|
|
146
|
+
### `deploylog edit <entry>`
|
|
147
|
+
|
|
148
|
+
Update an entry. With no field flags on an interactive terminal, your `$EDITOR` opens prefilled with the current body. If the server rejects an edited body, it is saved to a recovery file, never lost.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
-t, --title <title> New title (may change a draft's slug; the CLI tells you)
|
|
152
|
+
-T, --type <type> Entry type
|
|
153
|
+
--version <version> Semver version (pass "" to clear)
|
|
154
|
+
-b, --body <markdown> New body
|
|
155
|
+
--body-file <path> Read the new body from a file (- for stdin)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `deploylog publish <entry>` (alias: `pub`) / `deploylog unpublish <entry>` (alias: `unpub`)
|
|
159
|
+
|
|
160
|
+
Publish a draft, or revert a published entry to draft. Publishing is idempotent: re-running is a no-op, and the email digest (Pro) is sent at most once per entry, ever. Unpublishing resets the publish date; republishing gets a new one.
|
|
161
|
+
|
|
162
|
+
### `deploylog delete <entry>` (alias: `rm`)
|
|
163
|
+
|
|
164
|
+
Delete an entry permanently. Prompts for confirmation on a terminal; requires `--yes` in CI or `--json` mode.
|
|
165
|
+
|
|
166
|
+
### `deploylog import github <repo>`
|
|
167
|
+
|
|
168
|
+
Backfill your existing GitHub releases as draft entries. Takes `owner/repo` or a github.com URL. Skips versions you already imported.
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
-p, --project <slug> Project slug (or set in .deploylog.yml)
|
|
172
|
+
--token <token> GitHub PAT for private repos / rate limits (or DEPLOYLOG_GITHUB_TOKEN); never stored
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `deploylog open [entry]` (alias: `o`)
|
|
176
|
+
|
|
177
|
+
Open the project's public changelog (or one entry's page) in your browser. Prints the URL when headless.
|
|
178
|
+
|
|
106
179
|
## Recipes
|
|
107
180
|
|
|
108
|
-
**
|
|
181
|
+
**Onboard a repo in one minute:**
|
|
109
182
|
|
|
110
183
|
```bash
|
|
111
|
-
deploylog
|
|
184
|
+
deploylog login --key dk_xxx
|
|
185
|
+
deploylog projects create "My App" --url https://myapp.dev
|
|
186
|
+
deploylog init
|
|
187
|
+
deploylog import github me/my-app # backfill old releases as drafts
|
|
188
|
+
deploylog list --drafts # review
|
|
189
|
+
deploylog publish v1-4-0
|
|
112
190
|
```
|
|
113
191
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
**AI-polished release notes:**
|
|
192
|
+
**Draft from recent commits, ship after review:**
|
|
117
193
|
|
|
118
194
|
```bash
|
|
119
|
-
deploylog push --from-git --ai-summarize
|
|
120
|
-
|
|
121
|
-
|
|
195
|
+
deploylog push --from-git --ai-summarize
|
|
196
|
+
deploylog view <slug> # read what the AI wrote
|
|
197
|
+
deploylog edit <slug> # tweak in $EDITOR
|
|
198
|
+
deploylog publish <slug>
|
|
122
199
|
```
|
|
123
200
|
|
|
124
|
-
|
|
201
|
+
**CI: publish on release, no prompts:**
|
|
125
202
|
|
|
126
|
-
|
|
203
|
+
```bash
|
|
204
|
+
deploylog push --from-git --ai-summarize --yes --publish --json
|
|
205
|
+
```
|
|
127
206
|
|
|
128
|
-
For
|
|
207
|
+
For GitHub Actions specifically, prefer the official Action: [`deploylogdev/action`](https://github.com/marketplace/actions/publish-to-deploylog).
|
|
129
208
|
|
|
130
|
-
-
|
|
209
|
+
**Agent-driven usage:** every command's `--json` output is stable and prompt-free; destructive operations refuse without an explicit `--yes`.
|
|
131
210
|
|
|
132
211
|
## Related
|
|
133
212
|
|
package/dist/api.d.ts
CHANGED
|
@@ -21,7 +21,55 @@ export interface Entry {
|
|
|
21
21
|
created_at: string;
|
|
22
22
|
}
|
|
23
23
|
export declare function listProjects(): Promise<Project[]>;
|
|
24
|
-
export declare function
|
|
24
|
+
export declare function createProject(name: string, websiteUrl?: string): Promise<Project>;
|
|
25
|
+
export interface GithubImportResult {
|
|
26
|
+
imported: number;
|
|
27
|
+
skipped: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function importGithub(projectSlug: string, repo: string, token?: string): Promise<GithubImportResult>;
|
|
30
|
+
export interface WhoAmI {
|
|
31
|
+
organization: {
|
|
32
|
+
name: string;
|
|
33
|
+
slug: string;
|
|
34
|
+
plan: string | null;
|
|
35
|
+
};
|
|
36
|
+
api_key: {
|
|
37
|
+
name: string;
|
|
38
|
+
prefix: string;
|
|
39
|
+
permissions: string[];
|
|
40
|
+
last_used_at: string | null;
|
|
41
|
+
created_at: string;
|
|
42
|
+
};
|
|
43
|
+
ai_usage: {
|
|
44
|
+
used: number;
|
|
45
|
+
limit: number | null;
|
|
46
|
+
month_key: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export declare function whoami(): Promise<WhoAmI>;
|
|
50
|
+
export interface ListEntriesFilters {
|
|
51
|
+
status?: 'draft' | 'published';
|
|
52
|
+
type?: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}
|
|
55
|
+
export declare function listEntries(projectSlug: string, filters?: ListEntriesFilters): Promise<Entry[]>;
|
|
56
|
+
/** Full entry including the markdown body — the single-entry GET. */
|
|
57
|
+
export interface EntryDetail extends Entry {
|
|
58
|
+
body_markdown: string;
|
|
59
|
+
updated_at: string | null;
|
|
60
|
+
}
|
|
61
|
+
export declare function getEntry(entryId: string): Promise<EntryDetail>;
|
|
62
|
+
export interface UpdateEntryInput {
|
|
63
|
+
title?: string;
|
|
64
|
+
body_markdown?: string;
|
|
65
|
+
entry_type?: string | null;
|
|
66
|
+
version?: string;
|
|
67
|
+
slug?: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function updateEntry(entryId: string, input: UpdateEntryInput): Promise<EntryDetail>;
|
|
70
|
+
export declare function deleteEntry(entryId: string): Promise<{
|
|
71
|
+
id: string;
|
|
72
|
+
}>;
|
|
25
73
|
export interface CreateEntryInput {
|
|
26
74
|
title: string;
|
|
27
75
|
body_markdown: string;
|
|
@@ -30,6 +78,16 @@ export interface CreateEntryInput {
|
|
|
30
78
|
publish?: boolean;
|
|
31
79
|
}
|
|
32
80
|
export declare function createEntry(projectSlug: string, input: CreateEntryInput): Promise<Entry>;
|
|
81
|
+
/** Entry row returned by the publish route, plus whether the state actually changed. */
|
|
82
|
+
export interface PublishStateEntry {
|
|
83
|
+
id: string;
|
|
84
|
+
title: string;
|
|
85
|
+
slug: string;
|
|
86
|
+
published: boolean;
|
|
87
|
+
published_at: string | null;
|
|
88
|
+
changed: boolean;
|
|
89
|
+
}
|
|
90
|
+
export declare function setEntryPublished(entryId: string, published: boolean): Promise<PublishStateEntry>;
|
|
33
91
|
export interface SummarizeInput {
|
|
34
92
|
project_slug?: string;
|
|
35
93
|
commits?: string[];
|
package/dist/api.js
CHANGED
|
@@ -46,8 +46,43 @@ async function request(path, options = {}) {
|
|
|
46
46
|
export async function listProjects() {
|
|
47
47
|
return request('/projects');
|
|
48
48
|
}
|
|
49
|
-
export async function
|
|
50
|
-
return request(
|
|
49
|
+
export async function createProject(name, websiteUrl) {
|
|
50
|
+
return request('/projects', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify({ name, website_url: websiteUrl ?? '' }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export async function importGithub(projectSlug, repo, token) {
|
|
56
|
+
return request(`/projects/${projectSlug}/import/github`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
body: JSON.stringify(token ? { repo, token } : { repo }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function whoami() {
|
|
62
|
+
return request('/whoami');
|
|
63
|
+
}
|
|
64
|
+
export async function listEntries(projectSlug, filters = {}) {
|
|
65
|
+
const params = new URLSearchParams();
|
|
66
|
+
if (filters.status)
|
|
67
|
+
params.set('status', filters.status);
|
|
68
|
+
if (filters.type)
|
|
69
|
+
params.set('type', filters.type);
|
|
70
|
+
if (filters.limit !== undefined)
|
|
71
|
+
params.set('limit', String(filters.limit));
|
|
72
|
+
const qs = params.toString();
|
|
73
|
+
return request(`/projects/${projectSlug}/entries${qs ? `?${qs}` : ''}`);
|
|
74
|
+
}
|
|
75
|
+
export async function getEntry(entryId) {
|
|
76
|
+
return request(`/entries/${entryId}`);
|
|
77
|
+
}
|
|
78
|
+
export async function updateEntry(entryId, input) {
|
|
79
|
+
return request(`/entries/${entryId}`, {
|
|
80
|
+
method: 'PATCH',
|
|
81
|
+
body: JSON.stringify(input),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export async function deleteEntry(entryId) {
|
|
85
|
+
return request(`/entries/${entryId}`, { method: 'DELETE' });
|
|
51
86
|
}
|
|
52
87
|
export async function createEntry(projectSlug, input) {
|
|
53
88
|
return request(`/projects/${projectSlug}/entries`, {
|
|
@@ -55,6 +90,12 @@ export async function createEntry(projectSlug, input) {
|
|
|
55
90
|
body: JSON.stringify(input),
|
|
56
91
|
});
|
|
57
92
|
}
|
|
93
|
+
export async function setEntryPublished(entryId, published) {
|
|
94
|
+
return request(`/entries/${entryId}/publish`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: JSON.stringify({ published }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
58
99
|
export async function summarize(input) {
|
|
59
100
|
return request('/ai-summarize', {
|
|
60
101
|
method: 'POST',
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type EditorResult = {
|
|
2
|
+
kind: 'edited';
|
|
3
|
+
body: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'unchanged';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'aborted';
|
|
8
|
+
message: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'no-editor';
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Round-trip a markdown body through $EDITOR (fallback: $VISUAL, then vi).
|
|
15
|
+
* Trailing newlines are normalized before the no-op check — editors append
|
|
16
|
+
* one on save, and that alone must not count as a change.
|
|
17
|
+
*/
|
|
18
|
+
export declare function editInEditor(initial: string): EditorResult;
|
|
19
|
+
/**
|
|
20
|
+
* Persist a body the server rejected so the user's writing is never lost —
|
|
21
|
+
* printed in the error message as a recoverable path.
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveRecoveryFile(body: string): string;
|
package/dist/editor.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Round-trip a markdown body through $EDITOR (fallback: $VISUAL, then vi).
|
|
7
|
+
* Trailing newlines are normalized before the no-op check — editors append
|
|
8
|
+
* one on save, and that alone must not count as a change.
|
|
9
|
+
*/
|
|
10
|
+
export function editInEditor(initial) {
|
|
11
|
+
const editor = process.env.EDITOR ?? process.env.VISUAL ?? 'vi';
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), 'deploylog-'));
|
|
13
|
+
const file = join(dir, 'entry.md');
|
|
14
|
+
writeFileSync(file, initial, 'utf8');
|
|
15
|
+
try {
|
|
16
|
+
const res = spawnSync(editor, [file], { stdio: 'inherit' });
|
|
17
|
+
if (res.error) {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'no-editor',
|
|
20
|
+
message: `Could not launch editor '${editor}'. Set $EDITOR, or pass --body / --body-file instead.`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (res.status !== 0) {
|
|
24
|
+
return { kind: 'aborted', message: `Editor exited with status ${res.status}; entry not changed.` };
|
|
25
|
+
}
|
|
26
|
+
const edited = readFileSync(file, 'utf8');
|
|
27
|
+
if (edited.replace(/\n+$/, '') === initial.replace(/\n+$/, '')) {
|
|
28
|
+
return { kind: 'unchanged' };
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'edited', body: edited };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Persist a body the server rejected so the user's writing is never lost —
|
|
38
|
+
* printed in the error message as a recoverable path.
|
|
39
|
+
*/
|
|
40
|
+
export function saveRecoveryFile(body) {
|
|
41
|
+
const dir = join(tmpdir(), 'deploylog-recovery');
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const file = join(dir, `entry-${process.pid}-${Date.now()}.md`);
|
|
44
|
+
writeFileSync(file, body, 'utf8');
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type Entry, type EntryDetail, type PublishStateEntry, type UpdateEntryInput } from './api.js';
|
|
2
|
+
import { type ProjectConfig } from './project-config.js';
|
|
3
|
+
import { type EditorResult } from './editor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Shared deps for the entry-lifecycle commands (publish/unpublish, and the
|
|
6
|
+
* view/edit/delete siblings that follow) — same DI shape as PushDeps.
|
|
7
|
+
*/
|
|
8
|
+
export interface EntryCommandDeps {
|
|
9
|
+
api: {
|
|
10
|
+
listEntries(slug: string): Promise<Entry[]>;
|
|
11
|
+
getEntry(id: string): Promise<EntryDetail>;
|
|
12
|
+
updateEntry(id: string, input: UpdateEntryInput): Promise<EntryDetail>;
|
|
13
|
+
deleteEntry(id: string): Promise<{
|
|
14
|
+
id: string;
|
|
15
|
+
}>;
|
|
16
|
+
setEntryPublished(id: string, published: boolean): Promise<PublishStateEntry>;
|
|
17
|
+
};
|
|
18
|
+
readProjectConfig(): ProjectConfig | null;
|
|
19
|
+
editor(initial: string): EditorResult;
|
|
20
|
+
saveRecovery(body: string): string;
|
|
21
|
+
readFile(path: string): string;
|
|
22
|
+
confirm(question: string): Promise<boolean>;
|
|
23
|
+
isTTY: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface SetPublishedOptions {
|
|
26
|
+
ref: string;
|
|
27
|
+
project?: string;
|
|
28
|
+
publish: boolean;
|
|
29
|
+
}
|
|
30
|
+
export type SetPublishedResult = {
|
|
31
|
+
kind: 'updated';
|
|
32
|
+
entry: PublishStateEntry;
|
|
33
|
+
publish: boolean;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'not-found';
|
|
36
|
+
message: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'missing-fields';
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function runSetPublished(opts: SetPublishedOptions, deps?: EntryCommandDeps): Promise<SetPublishedResult>;
|
|
42
|
+
export interface ViewOptions {
|
|
43
|
+
ref: string;
|
|
44
|
+
project?: string;
|
|
45
|
+
}
|
|
46
|
+
export type ViewResult = {
|
|
47
|
+
kind: 'found';
|
|
48
|
+
entry: EntryDetail;
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'not-found';
|
|
51
|
+
message: string;
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'missing-fields';
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
56
|
+
export declare function runView(opts: ViewOptions, deps?: EntryCommandDeps): Promise<ViewResult>;
|
|
57
|
+
export interface EditOptions {
|
|
58
|
+
ref: string;
|
|
59
|
+
project?: string;
|
|
60
|
+
title?: string;
|
|
61
|
+
type?: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
body?: string;
|
|
64
|
+
bodyFile?: string;
|
|
65
|
+
}
|
|
66
|
+
export type EditResult = {
|
|
67
|
+
kind: 'updated';
|
|
68
|
+
entry: EntryDetail;
|
|
69
|
+
previousSlug: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'not-found';
|
|
72
|
+
message: string;
|
|
73
|
+
} | {
|
|
74
|
+
kind: 'missing-fields';
|
|
75
|
+
message: string;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'cancelled';
|
|
78
|
+
message: string;
|
|
79
|
+
} | {
|
|
80
|
+
kind: 'unchanged';
|
|
81
|
+
message: string;
|
|
82
|
+
} | {
|
|
83
|
+
kind: 'body-rejected';
|
|
84
|
+
message: string;
|
|
85
|
+
recoveryPath: string;
|
|
86
|
+
};
|
|
87
|
+
export declare function runEdit(opts: EditOptions, deps?: EntryCommandDeps): Promise<EditResult>;
|
|
88
|
+
export interface DeleteOptions {
|
|
89
|
+
ref: string;
|
|
90
|
+
project?: string;
|
|
91
|
+
yes?: boolean;
|
|
92
|
+
}
|
|
93
|
+
export type DeleteResult = {
|
|
94
|
+
kind: 'deleted';
|
|
95
|
+
id: string;
|
|
96
|
+
title: string;
|
|
97
|
+
} | {
|
|
98
|
+
kind: 'cancelled';
|
|
99
|
+
message: string;
|
|
100
|
+
} | {
|
|
101
|
+
kind: 'confirm-required';
|
|
102
|
+
message: string;
|
|
103
|
+
} | {
|
|
104
|
+
kind: 'not-found';
|
|
105
|
+
message: string;
|
|
106
|
+
} | {
|
|
107
|
+
kind: 'missing-fields';
|
|
108
|
+
message: string;
|
|
109
|
+
};
|
|
110
|
+
export declare function runDelete(opts: DeleteOptions, deps?: EntryCommandDeps): Promise<DeleteResult>;
|
|
111
|
+
export declare const defaultEntryCommandDeps: EntryCommandDeps;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { listEntries, getEntry, updateEntry, deleteEntry, setEntryPublished, ApiError, } from './api.js';
|
|
3
|
+
import { readProjectConfig } from './project-config.js';
|
|
4
|
+
import { resolveEntryRef } from './resolve.js';
|
|
5
|
+
import { editInEditor, saveRecoveryFile } from './editor.js';
|
|
6
|
+
import { defaultPushDeps } from './push.js';
|
|
7
|
+
export async function runSetPublished(opts, deps = defaultEntryCommandDeps) {
|
|
8
|
+
const projectConfig = deps.readProjectConfig();
|
|
9
|
+
const slug = opts.project ?? projectConfig?.project;
|
|
10
|
+
if (!slug) {
|
|
11
|
+
return {
|
|
12
|
+
kind: 'missing-fields',
|
|
13
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const resolved = await resolveEntryRef(opts.ref, slug, deps.api.listEntries);
|
|
17
|
+
if (resolved.kind === 'not-found') {
|
|
18
|
+
return { kind: 'not-found', message: resolved.message };
|
|
19
|
+
}
|
|
20
|
+
const entry = await deps.api.setEntryPublished(resolved.id, opts.publish);
|
|
21
|
+
return { kind: 'updated', entry, publish: opts.publish };
|
|
22
|
+
}
|
|
23
|
+
export async function runView(opts, deps = defaultEntryCommandDeps) {
|
|
24
|
+
const projectConfig = deps.readProjectConfig();
|
|
25
|
+
const slug = opts.project ?? projectConfig?.project;
|
|
26
|
+
if (!slug) {
|
|
27
|
+
return {
|
|
28
|
+
kind: 'missing-fields',
|
|
29
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const resolved = await resolveEntryRef(opts.ref, slug, deps.api.listEntries);
|
|
33
|
+
if (resolved.kind === 'not-found') {
|
|
34
|
+
return { kind: 'not-found', message: resolved.message };
|
|
35
|
+
}
|
|
36
|
+
const entry = await deps.api.getEntry(resolved.id);
|
|
37
|
+
return { kind: 'found', entry };
|
|
38
|
+
}
|
|
39
|
+
export async function runEdit(opts, deps = defaultEntryCommandDeps) {
|
|
40
|
+
const projectConfig = deps.readProjectConfig();
|
|
41
|
+
const slug = opts.project ?? projectConfig?.project;
|
|
42
|
+
if (!slug) {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'missing-fields',
|
|
45
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const resolved = await resolveEntryRef(opts.ref, slug, deps.api.listEntries);
|
|
49
|
+
if (resolved.kind === 'not-found') {
|
|
50
|
+
return { kind: 'not-found', message: resolved.message };
|
|
51
|
+
}
|
|
52
|
+
const current = await deps.api.getEntry(resolved.id);
|
|
53
|
+
const hasFieldFlags = opts.title !== undefined ||
|
|
54
|
+
opts.type !== undefined ||
|
|
55
|
+
opts.version !== undefined ||
|
|
56
|
+
opts.body !== undefined ||
|
|
57
|
+
opts.bodyFile !== undefined;
|
|
58
|
+
let body = opts.body;
|
|
59
|
+
let bodyFromEditor = false;
|
|
60
|
+
if (opts.bodyFile !== undefined && body === undefined) {
|
|
61
|
+
try {
|
|
62
|
+
body = deps.readFile(opts.bodyFile);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { kind: 'missing-fields', message: `Could not read body file '${opts.bodyFile}'.` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!hasFieldFlags) {
|
|
69
|
+
if (!deps.isTTY) {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'missing-fields',
|
|
72
|
+
message: 'Nothing to edit. Pass --title / --type / --version / --body / --body-file (the $EDITOR flow needs an interactive terminal).',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const edited = deps.editor(current.body_markdown);
|
|
76
|
+
switch (edited.kind) {
|
|
77
|
+
case 'unchanged':
|
|
78
|
+
return { kind: 'unchanged', message: 'No changes made; entry left as is.' };
|
|
79
|
+
case 'aborted':
|
|
80
|
+
return { kind: 'cancelled', message: edited.message };
|
|
81
|
+
case 'no-editor':
|
|
82
|
+
return { kind: 'missing-fields', message: edited.message };
|
|
83
|
+
case 'edited':
|
|
84
|
+
body = edited.body;
|
|
85
|
+
bodyFromEditor = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const input = {};
|
|
89
|
+
if (opts.title !== undefined)
|
|
90
|
+
input.title = opts.title;
|
|
91
|
+
if (opts.type !== undefined)
|
|
92
|
+
input.entry_type = opts.type;
|
|
93
|
+
if (opts.version !== undefined)
|
|
94
|
+
input.version = opts.version;
|
|
95
|
+
if (body !== undefined)
|
|
96
|
+
input.body_markdown = body;
|
|
97
|
+
try {
|
|
98
|
+
const entry = await deps.api.updateEntry(resolved.id, input);
|
|
99
|
+
return { kind: 'updated', entry, previousSlug: current.slug };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// A rejected editor-authored body must never be discarded — park it on
|
|
103
|
+
// disk and point at it.
|
|
104
|
+
if (bodyFromEditor && err instanceof ApiError && body !== undefined) {
|
|
105
|
+
const recoveryPath = deps.saveRecovery(body);
|
|
106
|
+
return {
|
|
107
|
+
kind: 'body-rejected',
|
|
108
|
+
message: `${err.message}\nYour edited body was saved to ${recoveryPath}`,
|
|
109
|
+
recoveryPath,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export async function runDelete(opts, deps = defaultEntryCommandDeps) {
|
|
116
|
+
const projectConfig = deps.readProjectConfig();
|
|
117
|
+
const slug = opts.project ?? projectConfig?.project;
|
|
118
|
+
if (!slug) {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'missing-fields',
|
|
121
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const resolved = await resolveEntryRef(opts.ref, slug, deps.api.listEntries);
|
|
125
|
+
if (resolved.kind === 'not-found') {
|
|
126
|
+
return { kind: 'not-found', message: resolved.message };
|
|
127
|
+
}
|
|
128
|
+
// Fetch first so the confirm prompt names what's about to be destroyed
|
|
129
|
+
// (and so a wrong-org/missing id 404s before any prompt).
|
|
130
|
+
const entry = await deps.api.getEntry(resolved.id);
|
|
131
|
+
if (!opts.yes) {
|
|
132
|
+
// Deleting is permanent — stricter than push: a non-interactive shell
|
|
133
|
+
// must opt in explicitly instead of auto-proceeding.
|
|
134
|
+
if (!deps.isTTY) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'confirm-required',
|
|
137
|
+
message: 'Refusing to delete without confirmation. Pass --yes in non-interactive mode.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const ok = await deps.confirm(`Delete '${entry.title}' (${entry.slug})? This is permanent.`);
|
|
141
|
+
if (!ok) {
|
|
142
|
+
return { kind: 'cancelled', message: 'Cancelled. Entry not deleted.' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await deps.api.deleteEntry(resolved.id);
|
|
146
|
+
return { kind: 'deleted', id: resolved.id, title: entry.title };
|
|
147
|
+
}
|
|
148
|
+
export const defaultEntryCommandDeps = {
|
|
149
|
+
api: { listEntries, getEntry, updateEntry, deleteEntry, setEntryPublished },
|
|
150
|
+
readProjectConfig: () => readProjectConfig(),
|
|
151
|
+
editor: editInEditor,
|
|
152
|
+
saveRecovery: saveRecoveryFile,
|
|
153
|
+
// '-' reads stdin so agents/CI can pipe a body without a temp file.
|
|
154
|
+
readFile: (path) => readFileSync(path === '-' ? 0 : path, 'utf8'),
|
|
155
|
+
confirm: (question) => defaultPushDeps.confirm(question),
|
|
156
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
157
|
+
};
|