tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
36
fork
atom
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
36
fork
atom
overview
issues
5
pulls
1
pipelines
chore: checkpoint
stevedylan.dev
1 month ago
00ae004c
64222257
+166
-168
1 changed file
expand all
collapse all
unified
split
packages
cli
src
commands
init.ts
+166
-168
packages/cli/src/commands/init.ts
···
8
8
select,
9
9
spinner,
10
10
log,
11
11
+
group,
11
12
} from "@clack/prompts";
12
13
import * as path from "path";
13
14
import { findConfig, generateConfigTemplate } from "../lib/config";
14
15
import { loadCredentials } from "../lib/credentials";
15
16
import { createAgent, createPublication } from "../lib/atproto";
16
17
import type { FrontmatterMapping } from "../lib/types";
17
17
-
import { exitOnCancel } from "../lib/prompts";
18
18
+
19
19
+
const onCancel = () => {
20
20
+
outro("Setup cancelled");
21
21
+
process.exit(0);
22
22
+
};
18
23
19
24
export const initCommand = command({
20
25
name: "init",
···
26
31
// Check if config already exists
27
32
const existingConfig = await findConfig();
28
33
if (existingConfig) {
29
29
-
const overwrite = exitOnCancel(
30
30
-
await confirm({
31
31
-
message: `Config already exists at ${existingConfig}. Overwrite?`,
32
32
-
initialValue: false,
33
33
-
}),
34
34
-
);
34
34
+
const overwrite = await confirm({
35
35
+
message: `Config already exists at ${existingConfig}. Overwrite?`,
36
36
+
initialValue: false,
37
37
+
});
38
38
+
if (overwrite === Symbol.for("cancel")) {
39
39
+
onCancel();
40
40
+
}
35
41
if (!overwrite) {
36
42
log.info("Keeping existing configuration");
37
43
return;
···
40
46
41
47
note("Follow the prompts to build your config for publishing", "Setup");
42
48
43
43
-
const siteUrl = exitOnCancel(
44
44
-
await text({
45
45
-
message: "Site URL (canonical URL of your site):",
46
46
-
placeholder: "https://example.com",
47
47
-
}),
48
48
-
);
49
49
-
50
50
-
if (!siteUrl) {
51
51
-
log.error("Site URL is required");
52
52
-
process.exit(1);
53
53
-
}
54
54
-
55
55
-
const contentDir = exitOnCancel(
56
56
-
await text({
57
57
-
message: "Content directory:",
58
58
-
placeholder: "./src/content/blog",
59
59
-
}),
49
49
+
// Site configuration group
50
50
+
const siteConfig = await group(
51
51
+
{
52
52
+
siteUrl: () =>
53
53
+
text({
54
54
+
message: "Site URL (canonical URL of your site):",
55
55
+
placeholder: "https://example.com",
56
56
+
validate: (value) => {
57
57
+
if (!value) return "Site URL is required";
58
58
+
try {
59
59
+
new URL(value);
60
60
+
} catch {
61
61
+
return "Please enter a valid URL";
62
62
+
}
63
63
+
},
64
64
+
}),
65
65
+
contentDir: () =>
66
66
+
text({
67
67
+
message: "Content directory:",
68
68
+
placeholder: "./src/content/blog",
69
69
+
}),
70
70
+
imagesDir: () =>
71
71
+
text({
72
72
+
message: "Cover images directory (leave empty to skip):",
73
73
+
placeholder: "./src/assets",
74
74
+
}),
75
75
+
publicDir: () =>
76
76
+
text({
77
77
+
message: "Public/static directory (for .well-known files):",
78
78
+
placeholder: "./public",
79
79
+
}),
80
80
+
outputDir: () =>
81
81
+
text({
82
82
+
message: "Build output directory (for link tag injection):",
83
83
+
placeholder: "./dist",
84
84
+
}),
85
85
+
pathPrefix: () =>
86
86
+
text({
87
87
+
message: "URL path prefix for posts:",
88
88
+
placeholder: "/posts, /blog, /articles, etc.",
89
89
+
}),
90
90
+
},
91
91
+
{ onCancel },
60
92
);
61
93
62
62
-
const imagesDir = exitOnCancel(
63
63
-
await text({
64
64
-
message: "Cover images directory (leave empty to skip):",
65
65
-
placeholder: "./src/assets",
66
66
-
}),
67
67
-
);
68
68
-
69
69
-
// Public/static directory for .well-known files
70
70
-
const publicDir = exitOnCancel(
71
71
-
await text({
72
72
-
message: "Public/static directory (for .well-known files):",
73
73
-
placeholder: "./public",
74
74
-
}),
75
75
-
);
76
76
-
77
77
-
// Output directory for inject command
78
78
-
const outputDir = exitOnCancel(
79
79
-
await text({
80
80
-
message: "Build output directory (for link tag injection):",
81
81
-
placeholder: "./dist",
82
82
-
}),
83
83
-
);
84
84
-
85
85
-
// Path prefix for posts
86
86
-
const pathPrefix = exitOnCancel(
87
87
-
await text({
88
88
-
message: "URL path prefix for posts:",
89
89
-
placeholder: "/posts, /blog, /articles, etc.",
90
90
-
}),
91
91
-
);
92
92
-
93
93
-
// Frontmatter mapping configuration
94
94
log.info(
95
95
"Configure your frontmatter field mappings (press Enter to use defaults):",
96
96
);
97
97
98
98
-
const titleField = exitOnCancel(
99
99
-
await text({
100
100
-
message: "Field name for title:",
101
101
-
defaultValue: "title",
102
102
-
placeholder: "title",
103
103
-
}),
104
104
-
);
105
105
-
106
106
-
const descField = exitOnCancel(
107
107
-
await text({
108
108
-
message: "Field name for description:",
109
109
-
defaultValue: "description",
110
110
-
placeholder: "description",
111
111
-
}),
112
112
-
);
113
113
-
114
114
-
const dateField = exitOnCancel(
115
115
-
await text({
116
116
-
message: "Field name for publish date:",
117
117
-
defaultValue: "publishDate",
118
118
-
placeholder: "publishDate, pubDate, date, etc.",
119
119
-
}),
120
120
-
);
121
121
-
122
122
-
const coverField = exitOnCancel(
123
123
-
await text({
124
124
-
message: "Field name for cover image:",
125
125
-
defaultValue: "ogImage",
126
126
-
placeholder: "ogImage, coverImage, image, hero, etc.",
127
127
-
}),
128
128
-
);
129
129
-
130
130
-
const tagsField = exitOnCancel(
131
131
-
await text({
132
132
-
message: "Field name for tags:",
133
133
-
defaultValue: "tags",
134
134
-
placeholder: "tags, categories, keywords, etc.",
135
135
-
}),
98
98
+
// Frontmatter mapping group
99
99
+
const frontmatterConfig = await group(
100
100
+
{
101
101
+
titleField: () =>
102
102
+
text({
103
103
+
message: "Field name for title:",
104
104
+
defaultValue: "title",
105
105
+
placeholder: "title",
106
106
+
}),
107
107
+
descField: () =>
108
108
+
text({
109
109
+
message: "Field name for description:",
110
110
+
defaultValue: "description",
111
111
+
placeholder: "description",
112
112
+
}),
113
113
+
dateField: () =>
114
114
+
text({
115
115
+
message: "Field name for publish date:",
116
116
+
defaultValue: "publishDate",
117
117
+
placeholder: "publishDate, pubDate, date, etc.",
118
118
+
}),
119
119
+
coverField: () =>
120
120
+
text({
121
121
+
message: "Field name for cover image:",
122
122
+
defaultValue: "ogImage",
123
123
+
placeholder: "ogImage, coverImage, image, hero, etc.",
124
124
+
}),
125
125
+
tagsField: () =>
126
126
+
text({
127
127
+
message: "Field name for tags:",
128
128
+
defaultValue: "tags",
129
129
+
placeholder: "tags, categories, keywords, etc.",
130
130
+
}),
131
131
+
},
132
132
+
{ onCancel },
136
133
);
137
134
135
135
+
// Build frontmatter mapping object
138
136
let frontmatterMapping: FrontmatterMapping | undefined = {};
139
137
140
140
-
if (titleField && titleField !== "title") {
141
141
-
frontmatterMapping.title = titleField;
138
138
+
if (frontmatterConfig.titleField !== "title") {
139
139
+
frontmatterMapping.title = frontmatterConfig.titleField;
142
140
}
143
143
-
if (descField && descField !== "description") {
144
144
-
frontmatterMapping.description = descField;
141
141
+
if (frontmatterConfig.descField !== "description") {
142
142
+
frontmatterMapping.description = frontmatterConfig.descField;
145
143
}
146
146
-
if (dateField && dateField !== "publishDate") {
147
147
-
frontmatterMapping.publishDate = dateField;
144
144
+
if (frontmatterConfig.dateField !== "publishDate") {
145
145
+
frontmatterMapping.publishDate = frontmatterConfig.dateField;
148
146
}
149
149
-
if (coverField && coverField !== "ogImage") {
150
150
-
frontmatterMapping.coverImage = coverField;
147
147
+
if (frontmatterConfig.coverField !== "ogImage") {
148
148
+
frontmatterMapping.coverImage = frontmatterConfig.coverField;
151
149
}
152
152
-
if (tagsField && tagsField !== "tags") {
153
153
-
frontmatterMapping.tags = tagsField;
150
150
+
if (frontmatterConfig.tagsField !== "tags") {
151
151
+
frontmatterMapping.tags = frontmatterConfig.tagsField;
154
152
}
155
153
156
154
// Only keep frontmatterMapping if it has any custom fields
···
159
157
}
160
158
161
159
// Publication setup
162
162
-
const publicationChoice = exitOnCancel(
163
163
-
await select({
164
164
-
message: "Publication setup:",
165
165
-
options: [
166
166
-
{ label: "Create a new publication", value: "create" },
167
167
-
{ label: "Use an existing publication AT URI", value: "existing" },
168
168
-
],
169
169
-
}),
170
170
-
);
160
160
+
const publicationChoice = await select({
161
161
+
message: "Publication setup:",
162
162
+
options: [
163
163
+
{ label: "Create a new publication", value: "create" },
164
164
+
{ label: "Use an existing publication AT URI", value: "existing" },
165
165
+
],
166
166
+
});
167
167
+
168
168
+
if (publicationChoice === Symbol.for("cancel")) {
169
169
+
onCancel();
170
170
+
}
171
171
172
172
let publicationUri: string;
173
173
-
let credentials = await loadCredentials();
173
173
+
const credentials = await loadCredentials();
174
174
175
175
if (publicationChoice === "create") {
176
176
// Need credentials to create a publication
···
195
195
process.exit(1);
196
196
}
197
197
198
198
-
const pubName = exitOnCancel(
199
199
-
await text({
200
200
-
message: "Publication name:",
201
201
-
placeholder: "My Blog",
202
202
-
}),
203
203
-
);
204
204
-
205
205
-
if (!pubName) {
206
206
-
log.error("Publication name is required");
207
207
-
process.exit(1);
208
208
-
}
209
209
-
210
210
-
const pubDescription = exitOnCancel(
211
211
-
await text({
212
212
-
message: "Publication description (optional):",
213
213
-
placeholder: "A blog about...",
214
214
-
}),
215
215
-
);
216
216
-
217
217
-
const iconPath = exitOnCancel(
218
218
-
await pathPrompt({
219
219
-
message: "Icon image path (leave empty to skip):",
220
220
-
}),
221
221
-
);
222
222
-
223
223
-
const showInDiscover = exitOnCancel(
224
224
-
await confirm({
225
225
-
message: "Show in Discover feed?",
226
226
-
initialValue: true,
227
227
-
}),
198
198
+
const publicationConfig = await group(
199
199
+
{
200
200
+
name: () =>
201
201
+
text({
202
202
+
message: "Publication name:",
203
203
+
placeholder: "My Blog",
204
204
+
validate: (value) => {
205
205
+
if (!value) return "Publication name is required";
206
206
+
},
207
207
+
}),
208
208
+
description: () =>
209
209
+
text({
210
210
+
message: "Publication description (optional):",
211
211
+
placeholder: "A blog about...",
212
212
+
}),
213
213
+
iconPath: () =>
214
214
+
text({
215
215
+
message: "Icon image path (leave empty to skip):",
216
216
+
placeholder: "./public/favicon.png",
217
217
+
}),
218
218
+
showInDiscover: () =>
219
219
+
confirm({
220
220
+
message: "Show in Discover feed?",
221
221
+
initialValue: true,
222
222
+
}),
223
223
+
},
224
224
+
{ onCancel },
228
225
);
229
226
230
227
s.start("Creating publication...");
231
228
try {
232
229
publicationUri = await createPublication(agent, {
233
233
-
url: siteUrl,
234
234
-
name: pubName,
235
235
-
description: pubDescription || undefined,
236
236
-
iconPath: iconPath || undefined,
237
237
-
showInDiscover,
230
230
+
url: siteConfig.siteUrl,
231
231
+
name: publicationConfig.name,
232
232
+
description: publicationConfig.description || undefined,
233
233
+
iconPath: publicationConfig.iconPath || undefined,
234
234
+
showInDiscover: publicationConfig.showInDiscover,
238
235
});
239
236
s.stop(`Publication created: ${publicationUri}`);
240
237
} catch (error) {
···
243
240
process.exit(1);
244
241
}
245
242
} else {
246
246
-
const uri = exitOnCancel(
247
247
-
await text({
248
248
-
message: "Publication AT URI:",
249
249
-
placeholder: "at://did:plc:.../site.standard.publication/...",
250
250
-
}),
251
251
-
);
243
243
+
const uri = await text({
244
244
+
message: "Publication AT URI:",
245
245
+
placeholder: "at://did:plc:.../site.standard.publication/...",
246
246
+
validate: (value) => {
247
247
+
if (!value) return "Publication URI is required";
248
248
+
},
249
249
+
});
252
250
253
253
-
if (!uri) {
254
254
-
log.error("Publication URI is required");
255
255
-
process.exit(1);
251
251
+
if (uri === Symbol.for("cancel")) {
252
252
+
onCancel();
256
253
}
257
257
-
publicationUri = uri;
254
254
+
publicationUri = uri as string;
258
255
}
259
256
260
257
// Get PDS URL from credentials (already loaded earlier)
···
262
259
263
260
// Generate config file
264
261
const configContent = generateConfigTemplate({
265
265
-
siteUrl: siteUrl,
266
266
-
contentDir: contentDir || "./content",
267
267
-
imagesDir: imagesDir || undefined,
268
268
-
publicDir: publicDir || "./public",
269
269
-
outputDir: outputDir || "./dist",
270
270
-
pathPrefix: pathPrefix || "/posts",
262
262
+
siteUrl: siteConfig.siteUrl,
263
263
+
contentDir: siteConfig.contentDir || "./content",
264
264
+
imagesDir: siteConfig.imagesDir || undefined,
265
265
+
publicDir: siteConfig.publicDir || "./public",
266
266
+
outputDir: siteConfig.outputDir || "./dist",
267
267
+
pathPrefix: siteConfig.pathPrefix || "/posts",
271
268
publicationUri,
272
269
pdsUrl,
273
270
frontmatter: frontmatterMapping,
···
279
276
log.success(`Configuration saved to ${configPath}`);
280
277
281
278
// Create .well-known/site.standard.publication file
282
282
-
const resolvedPublicDir = path.isAbsolute(publicDir || "./public")
283
283
-
? publicDir || "./public"
284
284
-
: path.join(process.cwd(), publicDir || "./public");
279
279
+
const publicDir = siteConfig.publicDir || "./public";
280
280
+
const resolvedPublicDir = path.isAbsolute(publicDir)
281
281
+
? publicDir
282
282
+
: path.join(process.cwd(), publicDir);
285
283
const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
286
284
const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
287
285