@yadurajfleetos/cli 0.2.0 → 0.3.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/dist/archive.js CHANGED
@@ -21,6 +21,25 @@ import { CliError, EXIT } from './api.js';
21
21
  * later rather than a slow upload for everybody now.
22
22
  */
23
23
  const ALWAYS_EXCLUDE = ['.git', 'node_modules', '.DS_Store'];
24
+ /**
25
+ * Patterns that must never be honoured, however the .dockerignore is written.
26
+ *
27
+ * `Dockerfile` in a .dockerignore is standard, recommended practice: a local
28
+ * `docker build` reads it from the host rather than from the context, so
29
+ * excluding it avoids shipping it twice. Here the context is built somewhere
30
+ * else, and the Dockerfile has to travel with it — honouring that line
31
+ * produces "failed to read dockerfile" on a context that is otherwise perfect.
32
+ *
33
+ * A bare `*` is the other one. It is the whitelist idiom, always paired with
34
+ * `!keep-this` lines, and since negations are not supported it would otherwise
35
+ * mean "exclude the entire project".
36
+ */
37
+ function mustNotExclude(pattern) {
38
+ const p = pattern.replace(/^\.?\//, '').replace(/^\*\*\//, '');
39
+ if (p === '*' || p === '**' || p === '.')
40
+ return true;
41
+ return /^\*?dockerfile/i.test(p);
42
+ }
24
43
  /**
25
44
  * Read .dockerignore into tar exclusion patterns.
26
45
  *
@@ -43,7 +62,8 @@ export async function ignorePatterns(dir) {
43
62
  .map((line) => line.trim())
44
63
  .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
45
64
  .map((line) => line.replace(/^\/+/, '').replace(/\/+$/, ''))
46
- .filter(Boolean);
65
+ .filter(Boolean)
66
+ .filter((line) => !mustNotExclude(line));
47
67
  return [...new Set([...ALWAYS_EXCLUDE, ...patterns])];
48
68
  }
49
69
  /**
@@ -7,10 +7,22 @@
7
7
  * a supported spelling. The value comes from a pipe or from a prompt with the
8
8
  * echo off, and nothing here ever prints one back.
9
9
  */
10
+ import { readFile } from 'node:fs/promises';
10
11
  import { request, requireFleet, CliError, EXIT } from '../api.js';
11
12
  import { c, table, relativeTime } from '../render.js';
12
13
  import { glyph } from '../ui.js';
13
14
  import { askSecret, canPrompt } from '../prompt.js';
15
+ import { parseDotenv } from '../dotenv.js';
16
+ import { declaredSecrets } from '../plan.js';
17
+ /** The manifest in the working directory, if there is one to read. */
18
+ async function declaredSecretsNearby() {
19
+ try {
20
+ return declaredSecrets(await readFile('fleet.yaml', 'utf8'));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
14
26
  const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
15
27
  /**
16
28
  * Read the value from a pipe when there is one, otherwise ask for it.
@@ -95,6 +107,99 @@ export const secretsCommand = {
95
107
  console.log(c.dim(' takes effect on the next deploy of any service that references it'));
96
108
  return;
97
109
  }
110
+ /* ── import ────────────────────────────────────────────────── */
111
+ if (sub === 'import') {
112
+ // Reading and choosing happen before anything touches the network, so
113
+ // `--dry-run` works on a plane, and a typo in a filename is not reported
114
+ // only after a sign-in prompt.
115
+ const file = key ?? '.env';
116
+ let source;
117
+ try {
118
+ source = await readFile(file, 'utf8');
119
+ }
120
+ catch {
121
+ throw new CliError(`Cannot read "${file}".\n` +
122
+ ` usage: fleet secrets import [file] (defaults to .env)`, EXIT.usage);
123
+ }
124
+ const parsed = parseDotenv(source);
125
+ for (const skip of parsed.skipped) {
126
+ console.log(`${glyph.warn} ${c.yellow('skipped')} line ${skip.line}: ${skip.reason}`);
127
+ }
128
+ for (const warning of parsed.warnings) {
129
+ console.log(`${glyph.warn} ${c.yellow('warning')} ${warning}`);
130
+ }
131
+ if (!parsed.entries.length) {
132
+ throw new CliError(`No usable assignments in "${file}".`, EXIT.usage);
133
+ }
134
+ // Which of them to send. The default is what the manifest declares,
135
+ // because a .env is half configuration and the store is only for the
136
+ // other half.
137
+ const only = typeof flags.only === 'string' ? flags.only.split(',').map((k) => k.trim()) : null;
138
+ let chosen;
139
+ let basis;
140
+ if (only) {
141
+ const missing = only.filter((k) => !parsed.entries.some((e) => e.key === k));
142
+ if (missing.length) {
143
+ throw new CliError(`Not in ${file}: ${missing.join(', ')}`, EXIT.usage);
144
+ }
145
+ chosen = parsed.entries.filter((e) => only.includes(e.key));
146
+ basis = '--only';
147
+ }
148
+ else if (flags.all) {
149
+ chosen = parsed.entries;
150
+ basis = '--all';
151
+ }
152
+ else {
153
+ const declared = await declaredSecretsNearby();
154
+ if (!declared) {
155
+ throw new CliError(`No fleet.yaml here to say which keys are secrets.\n` +
156
+ ` Pick them: fleet secrets import ${file} --only KEY,OTHER_KEY\n` +
157
+ ` Or send it all: fleet secrets import ${file} --all`, EXIT.usage);
158
+ }
159
+ chosen = parsed.entries.filter((e) => declared.has(e.key));
160
+ basis = 'fleet.yaml';
161
+ // Named in the manifest but absent from the file: the deploy will be
162
+ // refused for a missing secret later, so say it now.
163
+ for (const [name, wanted] of declared) {
164
+ if (!parsed.entries.some((e) => e.key === name)) {
165
+ console.log(`${glyph.warn} ${c.yellow('missing')} ${c.bold(name)} is declared by ${wanted.join(', ')} but is not in ${file}`);
166
+ }
167
+ }
168
+ }
169
+ if (!chosen.length) {
170
+ throw new CliError(`Nothing in "${file}" matches ${basis === 'fleet.yaml' ? 'the secrets fleet.yaml declares' : basis}.\n` +
171
+ ` Send everything with --all, or name keys with --only KEY,OTHER_KEY`, EXIT.usage);
172
+ }
173
+ if (flags['dry-run']) {
174
+ const scope = service ? ` for ${c.bold(service)}` : '';
175
+ console.log(`\n ${c.dim(`would store from ${file}${scope}, chosen by ${basis}`)}\n`);
176
+ for (const entry of chosen)
177
+ console.log(` ${c.bold(entry.key)} ${c.dim(`(line ${entry.line})`)}`);
178
+ console.log(c.dim(`\n ${chosen.length} key(s). No values are shown, here or ever.`));
179
+ return;
180
+ }
181
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
182
+ const target = service ? await resolveServiceId(fleetId, service) : null;
183
+ const where = target ? ` for ${c.bold(target.name)}` : '';
184
+ let stored = 0;
185
+ let replaced = 0;
186
+ for (const entry of chosen) {
187
+ const path = target
188
+ ? `/services/${target.id}/secrets/${encodeURIComponent(entry.key)}`
189
+ : `/fleets/${fleetId}/secrets/${encodeURIComponent(entry.key)}`;
190
+ const { body } = await request('PUT', path, { body: { value: entry.value } });
191
+ if (body.created)
192
+ stored++;
193
+ else
194
+ replaced++;
195
+ console.log(`${glyph.ok} ${c.green(body.created ? 'stored' : 'replaced')} ${c.bold(entry.key)}${where}`);
196
+ }
197
+ const untouched = parsed.entries.length - chosen.length;
198
+ console.log(c.dim(`\n ${stored} stored, ${replaced} replaced` +
199
+ (untouched ? `; ${untouched} other key(s) in ${file} left alone` : '')));
200
+ console.log(c.dim(' takes effect on the next deploy of any service that references them'));
201
+ return;
202
+ }
98
203
  /* ── rm ────────────────────────────────────────────────────── */
99
204
  if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
100
205
  if (!key)
@@ -112,6 +217,10 @@ export const secretsCommand = {
112
217
  }
113
218
  throw new CliError('usage: fleet secrets [ls]\n' +
114
219
  ' fleet secrets set <KEY> [--service <name>]\n' +
115
- ' fleet secrets rm <KEY> [--service <name>]', EXIT.usage);
220
+ ' fleet secrets rm <KEY> [--service <name>]\n' +
221
+ ' fleet secrets import [file] [--all | --only A,B] [--service <name>] [--dry-run]\n' +
222
+ '\n' +
223
+ ' import reads a .env (default: ./.env) and stores the keys fleet.yaml\n' +
224
+ ' declares as secrets. --all sends every key in the file instead.', EXIT.usage);
116
225
  },
117
226
  };
@@ -6,7 +6,7 @@ import { task, glyph } from '../ui.js';
6
6
  import { withLadder } from '../ladder.js';
7
7
  import { ask, canPrompt, confirm, selectOrThrow } from '../prompt.js';
8
8
  import { DEPLOY_STEPS, follow, phaseWalker, } from '../progress.js';
9
- import { planFromManifest } from '../plan.js';
9
+ import { planFromManifest, projectNameFor } from '../plan.js';
10
10
  import { uploadContext, humanBytes } from '../archive.js';
11
11
  const manifestPath = (given) => given ?? 'fleet.yaml';
12
12
  async function readManifest(path) {
@@ -40,10 +40,12 @@ export const applyCommand = {
40
40
  async run(args, flags) {
41
41
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
42
42
  const manifest = await readManifest(manifestPath(args[0]));
43
- const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
43
+ const body = await task(`applying ${manifestPath(args[0])}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
44
+ body: { manifest, project: projectNameFor(process.cwd()) },
45
+ })).body, {
44
46
  done: (b) => b.created.length || b.updated.length
45
- ? `applied ${b.created.length + b.updated.length} service(s)`
46
- : 'no changes',
47
+ ? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
48
+ : `no changes in project ${b.project}`,
47
49
  });
48
50
  if (flags.json)
49
51
  return console.log(JSON.stringify(body, null, 2));
@@ -71,14 +73,29 @@ export const servicesCommand = {
71
73
  return console.log(JSON.stringify(body.services, null, 2));
72
74
  if (!body.services.length)
73
75
  return console.log('No services. Run `fleet apply` with a fleet.yaml.');
74
- console.log(table(['service', 'url', 'placement', 'node', 'sha', 'status'], body.services.map((s) => [
75
- s.name + (s.persistentVolume ? c.dim(' ') : ''),
76
- s.domain ?? s.hostname ?? c.dim('—'),
77
- s.placementPolicy,
78
- s.current?.nodeName ?? c.dim('—'),
79
- s.current?.gitSha?.slice(0, 7) ?? c.dim(''),
80
- s.current ? statusColour(s.current.status) : c.dim('not deployed'),
81
- ])));
76
+ // Grouped by project. A fleet.yaml describes a stack, and listing its
77
+ // services flat among somebody else's is how four related things came to
78
+ // look like four unrelated ones.
79
+ const byProject = new Map();
80
+ for (const s of body.services) {
81
+ const key = s.project || 'default';
82
+ const group = byProject.get(key) ?? [];
83
+ group.push(s);
84
+ byProject.set(key, group);
85
+ }
86
+ for (const [project, group] of [...byProject].sort((a, b) => a[0].localeCompare(b[0]))) {
87
+ const running = group.filter((s) => s.current?.status === 'running').length;
88
+ const ram = group.reduce((sum, s) => sum + s.requestRamMb, 0);
89
+ console.log(`\n${c.bold(project)} ${c.dim(`${running}/${group.length} running · ${mb(ram)}`)}`);
90
+ console.log(table(['service', 'url', 'placement', 'node', 'sha', 'status'], group.map((s) => [
91
+ s.name + (s.persistentVolume ? c.dim(' ⛁') : ''),
92
+ s.domain ?? s.hostname ?? c.dim('—'),
93
+ s.placementPolicy,
94
+ s.current?.nodeName ?? c.dim('—'),
95
+ s.current?.gitSha?.slice(0, 7) ?? c.dim('—'),
96
+ s.current ? statusColour(s.current.status) : c.dim('not deployed'),
97
+ ])));
98
+ }
82
99
  },
83
100
  };
84
101
  async function findService(fleetId, name) {
@@ -14,7 +14,7 @@ import { c } from '../render.js';
14
14
  import { task, glyph } from '../ui.js';
15
15
  import { withLadder } from '../ladder.js';
16
16
  import { DEPLOY_STEPS, follow, phaseWalker } from '../progress.js';
17
- import { planFromManifest, deployOrder } from '../plan.js';
17
+ import { planFromManifest, deployOrder, projectNameFor } from '../plan.js';
18
18
  import { uploadContext, humanBytes } from '../archive.js';
19
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
20
  export const upCommand = {
@@ -46,10 +46,12 @@ export const upCommand = {
46
46
  }
47
47
  // ── Step 2: read and apply the manifest ───────────────────────────
48
48
  const manifest = await readFile(manifestPath, 'utf8');
49
- const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
49
+ const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, {
50
+ body: { manifest, project: projectNameFor(process.cwd()) },
51
+ })).body, {
50
52
  done: (b) => b.created.length || b.updated.length
51
- ? `applied ${b.created.length + b.updated.length} service(s)`
52
- : 'no changes',
53
+ ? `applied ${b.created.length + b.updated.length} service(s) to project ${b.project}`
54
+ : `no changes in project ${b.project}`,
53
55
  });
54
56
  for (const w of applyResult.warnings) {
55
57
  console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
package/dist/dotenv.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Reading a .env file.
3
+ *
4
+ * Deliberately not a general dotenv implementation. This one feeds a
5
+ * credential store, which changes the trade-offs: a value that is silently
6
+ * altered on the way in fails authentication somewhere far away from here,
7
+ * with nothing to point back at this file. So the rules are narrow, and
8
+ * anything ambiguous is reported rather than guessed at.
9
+ */
10
+ /** The same shape the control plane accepts as an environment variable name. */
11
+ const KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/;
12
+ export function parseDotenv(source) {
13
+ const entries = [];
14
+ const skipped = [];
15
+ const warnings = [];
16
+ const seen = new Set();
17
+ const lines = source.split(/\r?\n/);
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const raw = lines[i];
20
+ const line = i + 1;
21
+ const trimmed = raw.trim();
22
+ if (!trimmed || trimmed.startsWith('#'))
23
+ continue;
24
+ // `export FOO=bar` is common in files meant to be sourced by a shell.
25
+ const withoutExport = trimmed.replace(/^export\s+/, '');
26
+ const eq = withoutExport.indexOf('=');
27
+ if (eq < 1) {
28
+ skipped.push({ line, text: trimmed, reason: 'not a KEY=VALUE assignment' });
29
+ continue;
30
+ }
31
+ const key = withoutExport.slice(0, eq).trim();
32
+ if (!KEY_PATTERN.test(key)) {
33
+ skipped.push({ line, text: key, reason: 'not a usable environment variable name' });
34
+ continue;
35
+ }
36
+ const rest = withoutExport.slice(eq + 1);
37
+ let value;
38
+ const quote = rest.trimStart()[0];
39
+ if (quote === '"' || quote === "'") {
40
+ const body = rest.trimStart();
41
+ const end = findClosingQuote(body, quote);
42
+ if (end < 0) {
43
+ // A multi-line value, or a typo. Either way, do not guess where it ends.
44
+ skipped.push({ line, text: key, reason: `unterminated ${quote === '"' ? 'double' : 'single'} quote` });
45
+ continue;
46
+ }
47
+ const inner = body.slice(1, end);
48
+ // Single quotes are literal, as in a shell. Double quotes take the usual
49
+ // escapes so a value can contain a newline.
50
+ value = quote === "'" ? inner : unescape(inner);
51
+ }
52
+ else {
53
+ value = rest.trim();
54
+ // A '#' after whitespace is a comment in most dotenv readers and part of
55
+ // the password in some. Truncating a credential is the worse mistake, so
56
+ // this keeps the whole value and says so.
57
+ if (/\s#/.test(value)) {
58
+ warnings.push(`${key} (line ${line}) contains " #" and was stored whole, comment included. ` +
59
+ `Quote the value if part of it is a comment.`);
60
+ }
61
+ }
62
+ if (seen.has(key)) {
63
+ // Later wins, as a shell would do, but a duplicate is worth saying aloud:
64
+ // two different values for one key is rarely intentional.
65
+ warnings.push(`${key} appears more than once; the value on line ${line} is the one used.`);
66
+ const previous = entries.findIndex((e) => e.key === key);
67
+ entries.splice(previous, 1);
68
+ }
69
+ seen.add(key);
70
+ entries.push({ key, value, line });
71
+ }
72
+ return { entries, skipped, warnings };
73
+ }
74
+ /** Index of the closing quote, skipping ones that are escaped. */
75
+ function findClosingQuote(body, quote) {
76
+ for (let i = 1; i < body.length; i++) {
77
+ if (body[i] === '\\' && quote === '"') {
78
+ i++;
79
+ continue;
80
+ }
81
+ if (body[i] === quote)
82
+ return i;
83
+ }
84
+ return -1;
85
+ }
86
+ function unescape(input) {
87
+ return input.replace(/\\(.)/g, (_, ch) => {
88
+ switch (ch) {
89
+ case 'n':
90
+ return '\n';
91
+ case 'r':
92
+ return '\r';
93
+ case 't':
94
+ return '\t';
95
+ case '\\':
96
+ return '\\';
97
+ case '"':
98
+ return '"';
99
+ default:
100
+ return `\\${ch}`;
101
+ }
102
+ });
103
+ }
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ const GROUPS = [
50
50
  ['rollback <service> [release]', 'Restore the previous or selected release'],
51
51
  ['secrets', 'List the fleet secret store'],
52
52
  ['secrets set <KEY>', 'Store a credential; the value is never echoed or logged'],
53
+ ['secrets import [.env]', 'Store the secrets fleet.yaml declares, read from a .env file'],
53
54
  ['secrets rm <KEY>', 'Remove a stored credential'],
54
55
  ['nodes cordon <name>', 'Stop scheduling new work onto a node'],
55
56
  ['nodes uncordon <name>', 'Allow scheduling again'],
package/dist/plan.js CHANGED
@@ -7,6 +7,23 @@
7
7
  * whose database is not up yet fails its health check like any other outage.
8
8
  */
9
9
  import { parse as parseYaml } from 'yaml';
10
+ /**
11
+ * What to call this manifest's services collectively when it does not say.
12
+ *
13
+ * The directory name, which is what Compose does and what a person would
14
+ * answer if asked "which project is this". Normalised to the same shape a
15
+ * service name has to be, so the server never rejects a name it derived.
16
+ */
17
+ export function projectNameFor(dir) {
18
+ const base = dir.split('/').filter(Boolean).pop() ?? 'default';
19
+ const slug = base
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 48)
24
+ .replace(/-+$/, '');
25
+ return slug || 'default';
26
+ }
10
27
  /**
11
28
  * Read the manifest the way the control plane will.
12
29
  *
@@ -28,6 +45,32 @@ export function planFromManifest(source) {
28
45
  };
29
46
  });
30
47
  }
48
+ /**
49
+ * Which secrets the manifest says it needs, and which services want each.
50
+ *
51
+ * A .env holds a mix — half configuration, half credentials — and only the
52
+ * credentials belong in the secret store. The manifest already draws that line
53
+ * by declaring `secrets:`, so importing can honour it rather than asking
54
+ * somebody to re-draw it at the command line.
55
+ */
56
+ export function declaredSecrets(source) {
57
+ const doc = parseYaml(source);
58
+ const services = doc?.services;
59
+ const declared = new Map();
60
+ if (!services || typeof services !== 'object')
61
+ return declared;
62
+ for (const [name, raw] of Object.entries(services)) {
63
+ const body = (raw ?? {});
64
+ if (!Array.isArray(body.secrets))
65
+ continue;
66
+ for (const key of body.secrets) {
67
+ if (typeof key !== 'string')
68
+ continue;
69
+ declared.set(key, [...(declared.get(key) ?? []), name]);
70
+ }
71
+ }
72
+ return declared;
73
+ }
31
74
  /**
32
75
  * Order services so a dependency is deployed before whatever depends on it.
33
76
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",