@donezone/cli 0.1.0 → 0.1.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.
Files changed (2) hide show
  1. package/dist/index.js +297 -15
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import * as fs from "node:fs/promises";
5
+ import path from "node:path";
2
6
  import { Command } from "commander";
7
+ const DEFAULT_TEMPLATE_REPO = "https://github.com/mccallofthewild/done-template.git";
3
8
  const program = new Command();
4
9
  program.name("done").description("Done developer toolkit (spec-first stub)").version("0.0.0-spec");
5
10
  /* NOTE: All of these commands should be expected to be run from a standalone binary, not from the context of this repository / workspace. */
@@ -13,6 +18,8 @@ program
13
18
  // TODO: build contracts
14
19
  /*
15
20
 
21
+ Should accept blob pattern.
22
+
16
23
  See how packages/demo/serve.ts handles Bun.build
17
24
 
18
25
  Output compiled js artifacts to <cwd>/dist folder.
@@ -28,20 +35,19 @@ program
28
35
  .action(() => notImplemented("dev"));
29
36
  program
30
37
  .command("init")
31
- .description("Set up a new Done project (see SPEC.md)")
32
- .action(() => {
33
- // TODO: create project template with frontend and backend
34
- /*
35
-
36
- Accept first argument for project name (`done init my-project`)
37
- Download template, apply naming adjustments etc using whatever
38
- JS project template generator library
39
-
40
- Put contents in `my-project` (apply name input) folder
41
-
42
- Install deps, and boot up full stack dev (`done dev`)
43
-
44
- */
38
+ .argument("[name]", "Project or contract name")
39
+ .option("-f, --force", "Overwrite existing files if they already exist")
40
+ .option("--template <repoOrPath>", "Template git repo or local folder (defaults to official Done template)")
41
+ .option("--no-install", "Skip Bun install after creating a brand new workspace")
42
+ .description("Set up a new Done workspace or add a contract to an existing workspace")
43
+ .action(async (rawName, options) => {
44
+ try {
45
+ await handleInit(rawName, options);
46
+ }
47
+ catch (error) {
48
+ console.error(error instanceof Error ? error.message : String(error));
49
+ process.exit(1);
50
+ }
45
51
  });
46
52
  program
47
53
  .command("deploy")
@@ -52,8 +58,284 @@ program
52
58
  program
53
59
  .command("test")
54
60
  .description("Run unit tests for contracts");
55
- program.parseAsync().catch((error) => notImplemented(error instanceof Error ? error.message : String(error)));
61
+ program.parseAsync().catch((error) => {
62
+ console.error(error instanceof Error ? error.message : String(error));
63
+ process.exit(1);
64
+ });
56
65
  function notImplemented(label) {
57
66
  console.error(`The command segment '${label}' is not implemented yet.\nSee packages/done-cli/SPEC.md for the planned behaviour.`);
58
67
  process.exit(1);
59
68
  }
69
+ async function handleInit(rawName, options) {
70
+ const cwd = process.cwd();
71
+ const workspace = await detectWorkspace(cwd);
72
+ if (workspace) {
73
+ if (!rawName) {
74
+ throw new Error("Contract name is required when running init inside an existing Done workspace.");
75
+ }
76
+ await scaffoldContract(workspace, rawName, options);
77
+ return;
78
+ }
79
+ if (!rawName) {
80
+ throw new Error("Project name is required when creating a brand new Done workspace.");
81
+ }
82
+ await scaffoldWorkspace(rawName, options);
83
+ }
84
+ async function detectWorkspace(root) {
85
+ const manifestPath = path.join(root, "done.json");
86
+ const configPath = path.join(root, "done.config.json");
87
+ if (!existsSync(manifestPath) || !existsSync(configPath)) {
88
+ return null;
89
+ }
90
+ const [manifestRaw, configRaw] = await Promise.all([
91
+ fs.readFile(manifestPath, "utf8"),
92
+ fs.readFile(configPath, "utf8"),
93
+ ]);
94
+ const manifest = JSON.parse(manifestRaw);
95
+ const config = JSON.parse(configRaw);
96
+ return { root, manifestPath, configPath, manifest, config };
97
+ }
98
+ async function scaffoldWorkspace(rawName, options) {
99
+ const slug = toSlug(rawName);
100
+ if (!slug) {
101
+ throw new Error(`Could not derive a valid directory name from '${rawName}'.`);
102
+ }
103
+ const templateRef = options.template ?? DEFAULT_TEMPLATE_REPO;
104
+ const cwd = process.cwd();
105
+ const targetDir = path.resolve(cwd, slug);
106
+ await prepareTargetDir(targetDir, options.force === true);
107
+ const localTemplatePath = await resolveLocalTemplate(templateRef);
108
+ if (localTemplatePath) {
109
+ await fs.cp(localTemplatePath, targetDir, { recursive: true });
110
+ }
111
+ else {
112
+ await runCommand("git", ["clone", "--depth", "1", templateRef, targetDir]);
113
+ }
114
+ await removeGitFolder(targetDir);
115
+ await updatePackageJson(path.join(targetDir, "package.json"), (pkg) => {
116
+ pkg.name = slug;
117
+ });
118
+ await updatePackageJson(path.join(targetDir, "frontend", "package.json"), (pkg) => {
119
+ pkg.name = `${slug}-frontend`;
120
+ });
121
+ if (options.install !== false) {
122
+ await runCommand("bun", ["install"], { cwd: targetDir });
123
+ }
124
+ console.log(`Done workspace created at ${path.relative(cwd, targetDir) || "."}.`);
125
+ console.log("Next steps:");
126
+ console.log(` cd ${path.relative(cwd, targetDir) || slug}`);
127
+ if (options.install === false) {
128
+ console.log(" bun install");
129
+ }
130
+ console.log(" bunx done dev");
131
+ }
132
+ async function scaffoldContract(workspace, rawName, options) {
133
+ const slug = toSlug(rawName);
134
+ if (!slug) {
135
+ throw new Error(`Could not derive a valid contract directory name from '${rawName}'.`);
136
+ }
137
+ const contractName = toContractName(slug);
138
+ const contractsDir = resolveContractsDir(workspace.root, workspace.config.contractsDir);
139
+ const contractDir = path.join(contractsDir, slug);
140
+ await ensureDirAvailable(contractDir, options.force === true);
141
+ await fs.mkdir(path.join(contractDir, "src"), { recursive: true });
142
+ const pkgJsonPath = path.join(contractDir, "package.json");
143
+ const pkgJson = {
144
+ name: slug,
145
+ version: "0.1.0",
146
+ private: true,
147
+ type: "module",
148
+ scripts: {
149
+ typecheck: "tsc -p tsconfig.json --noEmit",
150
+ },
151
+ dependencies: {
152
+ "@donezone/contract-types": "^0.1.0",
153
+ },
154
+ };
155
+ await writeJson(pkgJsonPath, pkgJson);
156
+ const rootTsconfig = path.join(workspace.root, "tsconfig.json");
157
+ const tsconfig = {
158
+ compilerOptions: {
159
+ module: "ESNext",
160
+ moduleResolution: "bundler",
161
+ target: "ES2022",
162
+ isolatedModules: true,
163
+ noEmit: true,
164
+ },
165
+ include: ["src/**/*.ts"],
166
+ };
167
+ if (existsSync(rootTsconfig)) {
168
+ tsconfig.extends = normalizeJsonPath(path.relative(contractDir, rootTsconfig));
169
+ }
170
+ await writeJson(path.join(contractDir, "tsconfig.json"), tsconfig);
171
+ const storeKey = `${slug}-greeting`;
172
+ const defaultGreeting = `Hello from ${contractName}`;
173
+ const contractSource = `const Greeting = Done.item<string>("${storeKey}");
174
+
175
+ interface SetGreetingBody {
176
+ greeting: string;
177
+ }
178
+
179
+ export default Done.serve()
180
+ .instantiate(async () => {
181
+ const existing = await Greeting.load();
182
+ if (!existing) {
183
+ await Greeting.save("${defaultGreeting}");
184
+ }
185
+ })
186
+ .query("/greeting", async () => {
187
+ const greeting = await Greeting.load();
188
+ return { greeting: greeting ?? "${defaultGreeting}" };
189
+ })
190
+ .transaction(
191
+ "/greeting",
192
+ async ({ body }) => {
193
+ const payload = (body ?? {}) as Partial<SetGreetingBody>;
194
+ const next = payload.greeting?.trim();
195
+
196
+ if (!next) {
197
+ throw new Error("greeting text required");
198
+ }
199
+
200
+ await Greeting.save(next);
201
+ return { greeting: next };
202
+ },
203
+ {
204
+ body: {
205
+ type: "object",
206
+ properties: {
207
+ greeting: { type: "string", minLength: 1, maxLength: 280 },
208
+ },
209
+ required: ["greeting"],
210
+ additionalProperties: false,
211
+ },
212
+ },
213
+ );
214
+ `;
215
+ await fs.writeFile(path.join(contractDir, "src", "index.ts"), contractSource, "utf8");
216
+ workspace.manifest.contracts ??= [];
217
+ const contractDirRel = normalizeJsonPath(path.relative(workspace.root, contractDir));
218
+ const entry = ensureDotSlash(`${contractDirRel}/src/index.ts`);
219
+ const outFile = ensureDotSlash(`${contractDirRel}/dist/${slug}.contract.js`);
220
+ const watch = [ensureDotSlash(`${contractDirRel}/src/**/*.ts`)];
221
+ const newEntry = {
222
+ name: contractName,
223
+ entry,
224
+ outFile,
225
+ watch,
226
+ instantiate: { admin: null },
227
+ };
228
+ const existingIndex = workspace.manifest.contracts.findIndex((contract) => contract.name === contractName);
229
+ if (existingIndex >= 0 && options.force !== true) {
230
+ throw new Error(`Contract '${contractName}' already exists in done.json. Use --force to replace it.`);
231
+ }
232
+ if (existingIndex >= 0) {
233
+ workspace.manifest.contracts[existingIndex] = newEntry;
234
+ }
235
+ else {
236
+ workspace.manifest.contracts.push(newEntry);
237
+ }
238
+ await writeJson(workspace.manifestPath, workspace.manifest);
239
+ console.log(`Created Done contract '${contractName}' in ${path.relative(workspace.root, contractDir)}`);
240
+ console.log("Next steps:");
241
+ console.log(" bunx done build --name", contractName);
242
+ console.log(" bunx done dev");
243
+ }
244
+ async function resolveLocalTemplate(reference) {
245
+ const potentialPath = path.isAbsolute(reference) ? reference : path.resolve(process.cwd(), reference);
246
+ try {
247
+ const stats = await fs.stat(potentialPath);
248
+ if (stats.isDirectory()) {
249
+ return potentialPath;
250
+ }
251
+ }
252
+ catch { }
253
+ return null;
254
+ }
255
+ async function prepareTargetDir(targetDir, force) {
256
+ if (!existsSync(targetDir)) {
257
+ return;
258
+ }
259
+ if (!force) {
260
+ throw new Error(`Directory '${path.relative(process.cwd(), targetDir) || targetDir}' already exists. Use --force to overwrite.`);
261
+ }
262
+ await fs.rm(targetDir, { recursive: true, force: true });
263
+ }
264
+ async function ensureDirAvailable(targetDir, force) {
265
+ if (!existsSync(targetDir)) {
266
+ return;
267
+ }
268
+ if (!force) {
269
+ throw new Error(`Directory '${path.relative(process.cwd(), targetDir) || targetDir}' already exists. Use --force to overwrite.`);
270
+ }
271
+ await fs.rm(targetDir, { recursive: true, force: true });
272
+ }
273
+ async function removeGitFolder(targetDir) {
274
+ await fs.rm(path.join(targetDir, ".git"), { recursive: true, force: true });
275
+ }
276
+ function resolveContractsDir(root, configured) {
277
+ if (!configured) {
278
+ return path.join(root, "contracts");
279
+ }
280
+ if (path.isAbsolute(configured)) {
281
+ return configured;
282
+ }
283
+ return path.resolve(root, configured);
284
+ }
285
+ async function updatePackageJson(file, mutator) {
286
+ try {
287
+ const current = JSON.parse(await fs.readFile(file, "utf8"));
288
+ mutator(current);
289
+ await writeJson(file, current);
290
+ }
291
+ catch (error) {
292
+ if (error.code === "ENOENT") {
293
+ return;
294
+ }
295
+ throw error;
296
+ }
297
+ }
298
+ function ensureDotSlash(value) {
299
+ return value.startsWith("./") || value.startsWith("../") ? value : `./${value}`;
300
+ }
301
+ function normalizeJsonPath(relativePath) {
302
+ const normalized = relativePath.split(path.sep).join("/");
303
+ return normalized.startsWith("./") || normalized.startsWith("../") ? normalized : `./${normalized}`;
304
+ }
305
+ function toSlug(input) {
306
+ return input
307
+ .trim()
308
+ .toLowerCase()
309
+ .replace(/[^a-z0-9]+/g, "-")
310
+ .replace(/^-+/, "")
311
+ .replace(/-+$/, "");
312
+ }
313
+ function toContractName(slug) {
314
+ const parts = slug.split(/[^a-zA-Z0-9]+/).filter(Boolean);
315
+ if (parts.length === 0) {
316
+ return "contract";
317
+ }
318
+ const [first, ...rest] = parts;
319
+ return first + rest.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
320
+ }
321
+ async function writeJson(file, data) {
322
+ const json = JSON.stringify(data, null, 2);
323
+ await fs.writeFile(file, `${json}\n`, "utf8");
324
+ }
325
+ async function runCommand(command, args, options = {}) {
326
+ await new Promise((resolve, reject) => {
327
+ const child = spawn(command, args, {
328
+ cwd: options.cwd,
329
+ stdio: "inherit",
330
+ });
331
+ child.on("close", (code) => {
332
+ if (code === 0) {
333
+ resolve();
334
+ }
335
+ else {
336
+ reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
337
+ }
338
+ });
339
+ child.on("error", (error) => reject(error));
340
+ });
341
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donezone/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {