@astris-labs/cli 0.1.2 → 0.1.3

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 CHANGED
@@ -30,7 +30,7 @@ Requests go to `https://api.astris.run`.
30
30
 
31
31
  ## Commands
32
32
 
33
- v0 commands are `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `list`, `logs`, `usage`, `plans`, `whoami`, `login`, `logout`, `app`, `preflight`, and `export`.
33
+ v0 commands are `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `compose`, `list`, `logs`, `usage`, `plans`, `whoami`, `next`, `login`, `logout`, `app`, `preflight`, and `export`.
34
34
 
35
35
  `--json` writes one JSON object to stdout. Without `--json`, success is text on stdout and an error is a sentence on stderr.
36
36
 
@@ -93,6 +93,18 @@ Optional flags are `--build-path`, `--dockerfile`, `--port`, `--build`, `--publi
93
93
 
94
94
  `astris database delete --id <id> --yes` calls `DELETE /v1/databases/:id` and sends no body. Without `--yes` it makes no request and says the database and its data are removed and that `--yes` confirms. A repeated `--id` is refused and makes no request. Human output is `Database removed.` `--json` prints one JSON object `{"database_id":"<id>","removed":true}`.
95
95
 
96
+ ### compose
97
+
98
+ `astris compose list` calls `GET /v1/compose`. Human output is one line per stack in the order the API returned: the id, the name, and the project name. An empty list prints nothing. `--json` prints one JSON object `{"compose":[...]}` of the stack rows.
99
+
100
+ `astris compose deploy --id <id>` calls `POST /v1/compose/:id/deploy` and sends no body. Human output is the stack id, project id, name, git URL, branch, compose path, memory limit, created_at, and updated_at, one per line. `--json` prints one JSON object `{"compose":{...}}`.
101
+
102
+ `astris compose start --id <id>` calls `POST /v1/compose/:id/start` and sends no body. Human output is the status the API returned, then the same stack fields as deploy, one per line. `--json` prints one JSON object `{"compose":{...},"status":"<status>"}`.
103
+
104
+ `astris compose stop --id <id>` calls `POST /v1/compose/:id/stop` and sends no body. Human output matches start. `--json` prints one JSON object `{"compose":{...},"status":"<status>"}`.
105
+
106
+ Without `--id`, or with an id that is not a stack id, deploy, start, and stop make no request and say `Send --id set to the compose stack id.`
107
+
96
108
  ### storage
97
109
 
98
110
  `astris storage create --name <name>` calls `POST /v1/buckets` with `name` after trim. Human output is the bucket id, the name, the endpoint, the bucket, the access key id, and the secret key, one value per line. The secret key is printed on this command only, so save it now. `--json` prints one JSON object of the response body: the bucket row and the credentials.
@@ -125,6 +137,10 @@ When storage is not configured, all four commands print `Storage is not availabl
125
137
 
126
138
  `astris whoami` calls `GET /v1/me`. It takes no flags besides `--json`. `--json` prints the account id, email, status, plan, and created_at, and the key id, name, and key_prefix. Human output is three lines: `Account: <email>`, `Plan: <plan>` (`Plan: none` when plan is null), and `Key: <name> (<key_prefix>)`.
127
139
 
140
+ ### next
141
+
142
+ `astris next` prints this account's next step as one sentence. `--json` prints `{step, sentence}`.
143
+
128
144
  ### login
129
145
 
130
146
  `astris login` takes no key as an argument. On a terminal it asks `Paste your Astris API key:` and does not show what is typed. With a pipe it reads the key from stdin. When the API accepts the key, it writes `Logged in.`
@@ -160,18 +176,22 @@ Exit code 0 means the command succeeded. Any error exits with a non-zero code. W
160
176
 
161
177
  Some arguments are refused on this machine, before any request.
162
178
 
163
- The command name is checked first. The words `help`, `--help`, and `-h` print the usage and the command names, exit 0, and make no request. `deploy`, `status`, `env`, `domains`, `restart`, `logs`, `rollback`, `diagnose`, `database`, `storage`, `list`, `usage`, `plans`, `whoami`, `login`, `logout`, `app`, `preflight`, and `export` are known. Any other name writes `Unknown command.` and makes no request.
179
+ The command name is checked first. The words `help`, `--help`, and `-h` print the usage and the command names, exit 0, and make no request. `deploy`, `status`, `env`, `domains`, `restart`, `logs`, `rollback`, `diagnose`, `database`, `storage`, `compose`, `list`, `usage`, `plans`, `whoami`, `next`, `login`, `logout`, `app`, `preflight`, and `export` are known. Any other name writes `Unknown command.` and makes no request.
164
180
 
165
181
  Login asks for the key on a terminal and reads it from stdin otherwise. Logout deletes that one file. Every other known command uses this sentence: When `ASTRIS_API_KEY` is set to a non-empty value, that value is the key, and the saved file is used only when the variable is missing or empty. A missing or empty variable and a missing or empty file write `ASTRIS_API_KEY is not set` to stderr and make no request. That is also true when `--json` is set.
166
182
 
167
183
  `login` and `logout` take no arguments. An argument is refused, and `logout` does not delete the file.
168
184
 
169
- After the key, `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, and `storage` refuse a flag they do not know, a flag with no value, and a bare word that is not a flag. The sentence is `Those arguments were refused. Check the flags and send them again.`
185
+ After the key, `deploy`, `status`, `env`, `domains`, `restart`, `diagnose`, `database`, `storage`, `compose`, and `next` refuse a flag they do not know, a flag with no value, and a bare word that is not a flag. The sentence is `Those arguments were refused. Check the flags and send them again.`
170
186
 
171
187
  `diagnose` requires `--app` set to an application id, the same check as `status`. `database` takes one word, `create`, `status`, `backup`, or `delete`. `create` requires `--project`, `--kind`, and `--name`. `--project` is a project id. `--kind` is exactly `postgres` or `redis`. `status` and `backup` each require `--id` set to a database id. `backup` is accepted. `delete` requires `--id` set to a database id and `--yes`, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. The plural `backups` is refused and makes no request.
172
188
 
173
189
  `storage` takes one word, `create`, `list`, `show`, or `delete`. `create` requires `--name`. `show` and `delete` each require `--id` set to a bucket id, and a `--id` that is not a bucket id makes no request. `delete` requires `--yes`, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. `list` takes no word, so `storage list buckets` is refused and makes no request.
174
190
 
191
+ `compose` takes one word, `list`, `deploy`, `start`, or `stop`. `deploy`, `start`, and `stop` each require `--id` set to a compose stack id, and a repeated `--id` is refused with `Those arguments were refused. Check the flags and send them again.` and makes no request. `list` rejects `--id`.
192
+
193
+ `next` takes no word and no flag besides `--json`.
194
+
175
195
  `domains` binds with no word, or verifies with the one word `verify`. `--hostname` with `--slug` is refused. An empty `--hostname`, or one containing a space, is refused before any request. Other hostname rules are left to the API. `verify` requires `--app` set to an application id and `--id` set to a domain id; a missing or non-UUID `--id`, and a word other than `verify`, make no request.
176
196
 
177
197
  `--app` is refused unless the value, after trim, is an id of eight, four, four, four, and twelve hexadecimal digits, with hyphens, in either case. The sentence is `Send --app set to the application id.` `status`, `env`, `domains`, and `restart` require that id. `deploy` requires it when `--app` is present or when `--hook` is sent (`--hook` without `--app` is refused with `Send --app set to the application id.`), and then refuses `--build`, `--publish-directory`, `--spa`, `--kind`, `--command`, and `--schedule`; the other create flags are ignored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astris-labs/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "The Astris CLI, a command-line client of the Astris API.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/commands.mjs CHANGED
@@ -16,6 +16,7 @@ const SENTENCE = {
16
16
  projectId: "Send --project set to the project id.",
17
17
  databaseFlags: "Those arguments were refused. Send --project, --kind, and --name.",
18
18
  databaseId: "Send --id set to the database id.",
19
+ composeId: "Send --id set to the compose stack id.",
19
20
  domainId: "Send --id set to the domain id.",
20
21
  git: "The Git URL must not include a user name or password.",
21
22
  // T-169: reused word for word from the MCP's SENTENCE.deployKey (T-139).
@@ -57,6 +58,7 @@ const HTTP_SENTENCE = new Map([
57
58
  [401, SENTENCE.unauthorized],
58
59
  [404, SENTENCE.notFound],
59
60
  [409, SENTENCE.taken],
61
+ [429, "This account asked for too many changes in a short time. Wait a little, then try again."],
60
62
  [502, SENTENCE.platform],
61
63
  ]);
62
64
 
@@ -1826,3 +1828,196 @@ export async function accountExport(args, io) {
1826
1828
  writeStdout(io, loaded.key, `${SENTENCE.exportAccepted}\n`);
1827
1829
  return { exitCode: 0 };
1828
1830
  }
1831
+
1832
+ const NEXT_OPTIONS = {
1833
+ json: { type: "boolean" },
1834
+ };
1835
+
1836
+ export async function next(args, io) {
1837
+ const loaded = loadKey(io);
1838
+ if (loaded.exit) return loaded.exit;
1839
+ const parsed = parseStrict(args, NEXT_OPTIONS, false);
1840
+ if (parsed === null || parsed.positionals.length > 0) {
1841
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
1842
+ }
1843
+ const json = parsed.values.json === true;
1844
+ const result = await call(io, loaded.key, { path: "/v1/me/next-step", method: "GET" });
1845
+ if (result.network) return fail(io, loaded.key, json, SENTENCE.platform);
1846
+ if (result.status !== 200) return fail(io, loaded.key, json, sentenceFor(result.status));
1847
+ const body = isRecord(result.body) ? result.body : null;
1848
+ const step = body?.step;
1849
+ const sentence = body?.sentence;
1850
+ if (typeof step !== "string" || typeof sentence !== "string") {
1851
+ return fail(io, loaded.key, json, SENTENCE.failed);
1852
+ }
1853
+ if (json) return succeed(io, loaded.key, json, { step, sentence }, "");
1854
+ return succeed(io, loaded.key, json, { step, sentence }, `${sentence}\n`);
1855
+ }
1856
+
1857
+ const COMPOSE_LIST_OPTIONS = {
1858
+ json: { type: "boolean" },
1859
+ };
1860
+
1861
+ const COMPOSE_ACTION_OPTIONS = {
1862
+ json: { type: "boolean" },
1863
+ id: { type: "string", multiple: true },
1864
+ };
1865
+
1866
+ const COMPOSE_UNION = {
1867
+ json: { type: "boolean" },
1868
+ id: { type: "string" },
1869
+ };
1870
+
1871
+ function composePositionals(args) {
1872
+ try {
1873
+ return parseArgs({
1874
+ args: Array.isArray(args) ? args : [],
1875
+ strict: false,
1876
+ allowPositionals: true,
1877
+ options: COMPOSE_UNION,
1878
+ }).positionals;
1879
+ } catch {
1880
+ return null;
1881
+ }
1882
+ }
1883
+
1884
+ function composeFields(value) {
1885
+ if (!isRecord(value)) return null;
1886
+ const id = value.id;
1887
+ const projectId = value.project_id;
1888
+ const name = value.name;
1889
+ const gitUrl = value.git_url;
1890
+ const branch = value.branch;
1891
+ const composePath = value.compose_path;
1892
+ const memoryLimit = value.memory_limit;
1893
+ const createdAt = value.created_at;
1894
+ const updatedAt = value.updated_at;
1895
+ if (typeof id !== "string" || typeof projectId !== "string" || typeof name !== "string") return null;
1896
+ if (typeof gitUrl !== "string" || typeof branch !== "string" || typeof composePath !== "string") return null;
1897
+ if (typeof createdAt !== "string" || typeof updatedAt !== "string") return null;
1898
+ if (typeof memoryLimit !== "number" || !Number.isInteger(memoryLimit)) return null;
1899
+ const view = {
1900
+ id,
1901
+ project_id: projectId,
1902
+ name,
1903
+ git_url: gitUrl,
1904
+ branch,
1905
+ compose_path: composePath,
1906
+ memory_limit: memoryLimit,
1907
+ created_at: createdAt,
1908
+ updated_at: updatedAt,
1909
+ };
1910
+ const projectName = value.project_name;
1911
+ if (typeof projectName === "string") {
1912
+ return { ...view, project_name: projectName };
1913
+ }
1914
+ return view;
1915
+ }
1916
+
1917
+ function composeLines(view) {
1918
+ return [
1919
+ view.id,
1920
+ view.project_id,
1921
+ view.name,
1922
+ view.git_url,
1923
+ view.branch,
1924
+ view.compose_path,
1925
+ String(view.memory_limit),
1926
+ view.created_at,
1927
+ view.updated_at,
1928
+ ];
1929
+ }
1930
+
1931
+ function composeListLine(view) {
1932
+ const base = `${view.id} ${view.name}`;
1933
+ return typeof view.project_name === "string" ? `${base} ${view.project_name}` : base;
1934
+ }
1935
+
1936
+ function requireComposeId(io, key, json, rawId) {
1937
+ const ids = Array.isArray(rawId) ? rawId : rawId === undefined ? [] : [rawId];
1938
+ if (ids.length > 1) return { exit: fail(io, key, json, SENTENCE.refused) };
1939
+ const id = appUuid(ids[0]);
1940
+ if (id === null) return { exit: fail(io, key, json, SENTENCE.composeId) };
1941
+ return { id };
1942
+ }
1943
+
1944
+ async function composeList(args, io, key) {
1945
+ const parsed = parseStrict(args, COMPOSE_LIST_OPTIONS, true);
1946
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "list") {
1947
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1948
+ }
1949
+ if (parsed.values.id !== undefined) return fail(io, key, wantsJson(args), SENTENCE.refused);
1950
+ const json = parsed.values.json === true;
1951
+ const result = await call(io, key, { path: "/v1/compose", method: "GET" });
1952
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1953
+ if (result.status !== 200) return fail(io, key, json, sentenceFor(result.status));
1954
+ const items = isRecord(result.body) && Array.isArray(result.body.compose) ? result.body.compose : null;
1955
+ if (items === null) return fail(io, key, json, SENTENCE.failed);
1956
+ const views = [];
1957
+ for (const item of items) {
1958
+ const row = composeFields(item);
1959
+ if (row === null) return fail(io, key, json, SENTENCE.failed);
1960
+ views.push(row);
1961
+ }
1962
+ const human = views.length === 0 ? "" : `${views.map((row) => composeListLine(row)).join("\n")}\n`;
1963
+ return succeed(io, key, json, { compose: views }, human);
1964
+ }
1965
+
1966
+ async function composeDeploy(args, io, key) {
1967
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
1968
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== "deploy") {
1969
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1970
+ }
1971
+ const json = parsed.values.json === true;
1972
+ const required = requireComposeId(io, key, json, parsed.values.id);
1973
+ if (required.exit) return required.exit;
1974
+ const result = await call(io, key, {
1975
+ path: `/v1/compose/${encodeURIComponent(required.id)}/deploy`,
1976
+ method: "POST",
1977
+ });
1978
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
1979
+ if (result.status !== 202) {
1980
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
1981
+ }
1982
+ const view = composeFields(isRecord(result.body) ? result.body.compose : null);
1983
+ if (view === null) return fail(io, key, json, SENTENCE.failed);
1984
+ const payload = { compose: view };
1985
+ return succeed(io, key, json, payload, `${composeLines(view).join("\n")}\n`);
1986
+ }
1987
+
1988
+ async function composeStartStop(args, io, key, action) {
1989
+ const parsed = parseStrict(args, COMPOSE_ACTION_OPTIONS, true);
1990
+ if (parsed === null || parsed.positionals.length !== 1 || parsed.positionals[0] !== action) {
1991
+ return fail(io, key, wantsJson(args), SENTENCE.refused);
1992
+ }
1993
+ const json = parsed.values.json === true;
1994
+ const required = requireComposeId(io, key, json, parsed.values.id);
1995
+ if (required.exit) return required.exit;
1996
+ const result = await call(io, key, {
1997
+ path: `/v1/compose/${encodeURIComponent(required.id)}/${action}`,
1998
+ method: "POST",
1999
+ });
2000
+ if (result.network) return fail(io, key, json, SENTENCE.platform);
2001
+ if (result.status !== 200) {
2002
+ return fail(io, key, json, upgradeMessage(result.status, result.body) ?? sentenceFor(result.status));
2003
+ }
2004
+ const body = isRecord(result.body) ? result.body : null;
2005
+ const view = body === null ? null : composeFields(body.compose);
2006
+ const statusText = body?.status;
2007
+ if (view === null || typeof statusText !== "string") return fail(io, key, json, SENTENCE.failed);
2008
+ const payload = { compose: view, status: statusText };
2009
+ const lines = [statusText, ...composeLines(view)];
2010
+ return succeed(io, key, json, payload, `${lines.join("\n")}\n`);
2011
+ }
2012
+
2013
+ export async function compose(args, io) {
2014
+ const loaded = loadKey(io);
2015
+ if (loaded.exit) return loaded.exit;
2016
+ const words = composePositionals(args);
2017
+ if (words === null || words.length !== 1) return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2018
+ if (words[0] === "list") return composeList(args, io, loaded.key);
2019
+ if (words[0] === "deploy") return composeDeploy(args, io, loaded.key);
2020
+ if (words[0] === "start") return composeStartStop(args, io, loaded.key, "start");
2021
+ if (words[0] === "stop") return composeStartStop(args, io, loaded.key, "stop");
2022
+ return fail(io, loaded.key, wantsJson(args), SENTENCE.refused);
2023
+ }
package/src/main.mjs CHANGED
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline";
4
4
  import { Writable } from "node:stream";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_BASE_URL } from "./client.mjs";
7
- import { accountExport, app, database, deploy, diagnose, domains, env, list, login, logout, logs, plans, preflight, restart, rollback, status, storage, usage, whoami } from "./commands.mjs";
7
+ import { accountExport, app, compose, database, deploy, diagnose, domains, env, list, login, logout, logs, next, plans, preflight, restart, rollback, status, storage, usage, whoami } from "./commands.mjs";
8
8
 
9
9
  const PROMPT = "Paste your Astris API key: ";
10
10
 
@@ -48,6 +48,7 @@ const handlers = new Map([
48
48
  ["diagnose", diagnose],
49
49
  ["database", database],
50
50
  ["storage", storage],
51
+ ["compose", compose],
51
52
  ["env", env],
52
53
  ["domains", domains],
53
54
  ["restart", restart],
@@ -56,6 +57,7 @@ const handlers = new Map([
56
57
  ["usage", usage],
57
58
  ["plans", plans],
58
59
  ["whoami", whoami],
60
+ ["next", next],
59
61
  ["rollback", rollback],
60
62
  ["login", login],
61
63
  ["logout", logout],