Skip to content

Commit bc45928

Browse files
committed
feat: a project is made before it is filled, so a typo cannot invent one
`upload` took two flags — --name to make a project, --to to add to one — which meant a child's second upload was typed differently from their first and learned so by failing. Those collapsed into one flag that found the project or made it. The cost was that a mistyped --project Snak stopped being a question and became a second project called "Snak" holding one file. So the flag stays one flag and stops making anything. --project names a project that has to be there already, and `projects create` is where one begins. Two commands for a first upload, on purpose: the moment a name is invented is now a moment somebody chose, rather than a side effect of putting a file somewhere. The API had no way to make an empty project. Create refuses a request carrying neither files nor a link, because at a screen that means the files were never attached — relaxing it would have swallowed that mistake, so the new way through is opt-in. Only a caller that sends AllowEmpty gets one, and the panel a child uploads from sends nothing of the sort and still hears about an empty selection. `projects create` refuses a name the locker already has. The API would take a second "Snake" without complaint, and that is precisely the ambiguity `upload` cannot resolve afterwards — better said while it is still one keystroke to pick another name. Upload's own guard is unchanged: a reference matching two projects is refused, listing both, and the id says which. With upload never creating, half of performUpload was unreachable and the dry run still carried a line about "a new project" that could no longer happen. Both are gone, and UploadTarget and the outcome's `added` with them — one endpoint, no branch. That also bought a plainer sentence for a pasted link, which used to report "Added 0 files" about something that had plainly worked. --to means what it always meant. --name is kept as a hidden spelling of --project, so a script written against the published version is told which command makes a project rather than that the flag does not exist.
1 parent 9f11cd2 commit bc45928

9 files changed

Lines changed: 245 additions & 140 deletions

File tree

README.md

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ Then:
2020

2121
```bash
2222
locker auth login
23-
locker upload ./my-game --name "Snake"
23+
locker projects create "Snake"
24+
locker upload ./my-game --project Snake
2425
```
2526

2627
It is not on the npm registry yet, so `npx @coderdojo-linz/locker` does not work
@@ -33,41 +34,53 @@ From a clone, it runs straight out of the tree:
3334
npm ci && npm run build && node dist/index.js --help
3435
```
3536

36-
## The four things you will actually do
37+
## The things you will actually do
3738

3839
```bash
39-
locker auth login # enter the code from your card
40-
locker status # what is in your locker, and how full it is
41-
locker upload ./my-project # save work into a new project
42-
locker download Snake # get work back out
40+
locker auth login # enter the code from your card
41+
locker status # what is in your locker, and how full it is
42+
locker projects create Snake # start a project
43+
locker upload ./my-game --project Snake # put work into it
44+
locker download Snake # get work back out
4345
```
4446

4547
`locker --help` lists everything. `locker <command> --help` explains one thing.
4648

4749
## Uploading
4850

49-
Always say where the files go — `--name` makes a new project, `--to` adds to one
50-
that is already there. Neither is guessed, because a project name is not unique
51-
and a coincidence is a poor way to decide where your work lands.
52-
53-
A folder keeps its shape, so this arrives as a `game` folder with `img/` still
54-
inside it:
51+
A project is made first, and then uploaded into. Two steps, one flag each time:
5552

5653
```bash
57-
locker upload ./game --name "Snake"
54+
locker projects create "Snake"
55+
locker upload ./game --project Snake
5856
```
5957

58+
`--project` takes a name or an id and never invents one. A name you have not
59+
made yet is refused, naming the command that would make it — so a mistyped
60+
`--project Snak` is a question rather than a second project called "Snak" with
61+
one file in it. Every upload after the first is typed exactly like this one:
62+
6063
```bash
61-
locker upload extra-level.py --to Snake
64+
locker upload extra-level.py --project Snake
6265
```
6366

67+
A folder keeps its shape, so `./game` arrives as a `game` folder with `img/`
68+
still inside it.
69+
70+
One thing is still never guessed for you: a name matching two projects is
71+
refused, listing both, because nothing stops you calling two of them "Game". Use
72+
the id from `locker projects list` to say which.
73+
6474
Look before you leap. `--dry-run` lists every file, the total, and how much room
6575
is left, without uploading anything:
6676

6777
```bash
68-
locker upload ./game --name "Snake" --dry-run
78+
locker upload ./game --project "Snake" --dry-run
6979
```
7080

81+
`--name` and `--to` are still accepted and both mean `--project`. `--to` behaves
82+
exactly as it always did; `--name` no longer makes a project, and says so.
83+
7184
`.git`, `node_modules` and similar are skipped, and the CLI tells you it skipped
7285
them. `--all` includes them.
7386

src/commands/projectsCreate.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { requirePositional } from '../runtime/args.js';
2+
import type { ProjectDetails } from '../api/types.js';
3+
import type { Command, CommandResult } from '../runtime/command.js';
4+
import { usageError } from '../runtime/exit.js';
5+
import { shortId } from '../runtime/format.js';
6+
import { fetchLocker, findProject } from '../domain/locker.js';
7+
8+
const MAXIMUM_NAME_LENGTH = 120;
9+
10+
/**
11+
* Makes a project with nothing in it yet.
12+
*
13+
* `upload` only ever adds to a project that is already there, so this is where
14+
* one begins. Two commands rather than one for a first upload, on purpose: it
15+
* means a mistyped `--project` is a refusal rather than a second project named
16+
* after the typo, and the moment a name is invented is a moment somebody chose
17+
* rather than a side effect of putting a file somewhere.
18+
*/
19+
export const projectsCreateCommand: Command = {
20+
path: ['projects', 'create'],
21+
summary: 'Make an empty project to upload into',
22+
description:
23+
'Makes a project with nothing in it. Put files in it with\n' +
24+
'"locker upload <paths...> --project <name>".',
25+
usage: 'locker projects create <name>',
26+
examples: [
27+
'locker projects create "Snake"',
28+
'locker projects create "Snake" && locker upload ./game --project Snake',
29+
],
30+
async run(context, input): Promise<CommandResult> {
31+
const code = context.requireCode();
32+
const name = requirePositional(
33+
input,
34+
0,
35+
'a name',
36+
'locker projects create "My project"',
37+
).trim();
38+
39+
if (name.length === 0 || name.length > MAXIMUM_NAME_LENGTH) {
40+
throw usageError(`A project name is between 1 and ${MAXIMUM_NAME_LENGTH} characters.`);
41+
}
42+
43+
// Names are not unique and the API would take a second "Snake" without
44+
// complaint, which is precisely the ambiguity `upload` then cannot resolve.
45+
// Said here, where it is still one keystroke to pick another name.
46+
const locker = await fetchLocker(context);
47+
if (findProject(locker, name)) {
48+
throw usageError(
49+
`This locker already has a project called "${name}".`,
50+
`locker upload ./files --project ${JSON.stringify(name)}`,
51+
);
52+
}
53+
54+
if (context.dryRun) {
55+
return {
56+
human: `Would make an empty project called "${name}".`,
57+
data: { dryRun: true, name },
58+
};
59+
}
60+
61+
const project = await context.api.request<ProjectDetails>(
62+
`/api/lockers/${encodeURIComponent(code)}/projects`,
63+
{
64+
method: 'POST',
65+
// Said outright: the API refuses a project with nothing in it unless the
66+
// caller asked for one, so that a screen which forgot to attach the
67+
// files still hears about it.
68+
body: { name, link: null, ticket: null, allowEmpty: true },
69+
notFound: { kind: 'credential' },
70+
},
71+
);
72+
73+
context.reporter.success(`Made "${project.name}" — empty for now.`);
74+
return {
75+
human: `${shortId(project.id)} ${project.name}`,
76+
data: project,
77+
};
78+
},
79+
};

src/commands/registry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { filesDeleteCommand } from './filesDelete.js';
1111
import { filesListCommand } from './filesList.js';
1212
import { filesRenameCommand } from './filesRename.js';
1313
import { filesReplaceCommand } from './filesReplace.js';
14+
import { projectsCreateCommand } from './projectsCreate.js';
1415
import { projectsDeleteCommand } from './projectsDelete.js';
1516
import { projectsListCommand } from './projectsList.js';
1617
import { projectsRenameCommand } from './projectsRename.js';
@@ -40,6 +41,7 @@ export const COMMANDS: Command[] = [
4041
authLogoutCommand,
4142
authStatusCommand,
4243

44+
projectsCreateCommand,
4345
projectsListCommand,
4446
projectsShowCommand,
4547
projectsRenameCommand,
@@ -66,7 +68,8 @@ export const COMMANDS: Command[] = [
6668
export const COMMON_TASKS: { command: string; blurb: string }[] = [
6769
{ command: 'locker auth login', blurb: 'Enter the code from your card' },
6870
{ command: 'locker status', blurb: 'See what is in your locker' },
69-
{ command: 'locker upload ./my-project', blurb: 'Save work into a new project' },
71+
{ command: 'locker projects create Snake', blurb: 'Start a project' },
72+
{ command: 'locker upload ./game --project Snake', blurb: 'Put work into it' },
7073
{ command: 'locker download <project>', blurb: 'Get work back out' },
7174
];
7275

0 commit comments

Comments
 (0)