nukebase 2.0.6 → 2.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.
package/README.md CHANGED
@@ -32,6 +32,10 @@ CLI sign-ins can deploy and manage projects but can't change billing, and stay
32
32
  signed in until you sign them out here or under **Signed-in devices** on the
33
33
  dashboard.
34
34
 
35
+ A project's settings — which account owns it, which region it lives in,
36
+ `exclude`, `backup` — are the `nukebase` section of its `package.json`.
37
+ Push and pull carry it, so a fresh clone is already linked.
38
+
35
39
  ## Projects
36
40
 
37
41
  ```
package/bin/nukebase.js CHANGED
@@ -24,7 +24,7 @@ if (!(args.has('version') && !args.command)) {
24
24
  const ACCOUNT_COMMANDS = ['login', 'logout', 'accounts', 'use', 'whoami', 'create', 'link'];
25
25
  const PROJECT_COMMANDS = [
26
26
  'push', 'pull', 'update', 'rollback', 'restorepoints', 'restore-points', 'backup', 'env',
27
- 'pause', 'restart', 'start', 'resume', 'delete', 'domain', 'logs', 'download',
27
+ 'pause', 'restart', 'resume', 'delete', 'domain', 'logs', 'download',
28
28
  'regiontransfer', 'region-transfer'
29
29
  ];
30
30
 
@@ -49,8 +49,7 @@ Accounts
49
49
  whoami who owns this project, and are you signed in
50
50
 
51
51
  New projects
52
- start [name] [size] outside a project: sign in if needed, then create
53
- (inside a project, start = restart)
52
+ start [name] [size] sign in if needed, then create and download
54
53
  create <name> [size] [email]
55
54
  make a project and download it, e.g.
56
55
  nukebase create demoapp123 256mb you@example.com
@@ -73,7 +72,7 @@ In a project folder (runs as the account that owns the project)
73
72
  then; discards writes since
74
73
  rollback list show restore points (also: restorepoints)
75
74
  pause stop the project (base fee + disk still accrue)
76
- restart | start | resume restart it; also resumes a paused project
75
+ restart | resume restart it; also resumes a paused project
77
76
  delete delete it on the server; asks for the name
78
77
  domain connect a custom domain, step by step
79
78
  domain <name> [--email e] connect in one go (DNS already set)
@@ -94,14 +93,27 @@ Options
94
93
  CI: set NUKEBASE_TOKEN and NUKEBASE_UID (create a build token on the
95
94
  dashboard under Signed-in devices).
96
95
 
97
- Sign-ins are kept in ~/.nukebase/credentials.json, never in a project.`);
96
+ Sign-ins are kept in ~/.nukebase/credentials.json, never in a project.
97
+ A project's settings (owner, region, exclude, backup) are the "nukebase"
98
+ section of its package.json.`);
98
99
  }
99
100
 
100
101
  let command = args.command;
101
102
 
102
- // `start` means two things: inside a project it restarts the app (as it always
103
- // has); anywhere else it is the new-user setup (sign in, create, download).
104
- if (command === 'start' && !require('../lib/project').findProject(process.cwd())) command = 'setup';
103
+ // `start` is the new-user setup (sign in, create, download). It used to double
104
+ // as a restart alias inside a project; now it stops there instead of guessing,
105
+ // so the same words do the same thing in every folder.
106
+ if (command === 'start') {
107
+ const here = require('../lib/project').findProject(process.cwd());
108
+ if (here) {
109
+ const { project } = require('../lib/project').readConfig(here);
110
+ console.error(`This folder is already the NukeBase project "${project}".`);
111
+ console.error(' nukebase restart restart it (resumes a paused project)');
112
+ console.error(' nukebase create <name> make a new project (run it elsewhere)');
113
+ process.exit(1);
114
+ }
115
+ command = 'setup';
116
+ }
105
117
 
106
118
  if (args.has('version') && !command) {
107
119
  console.log(version);
@@ -7,7 +7,7 @@ const { spawnSync } = require('child_process');
7
7
  const creds = require('./credentials');
8
8
  const login = require('./login');
9
9
  const unzip = require('./unzip');
10
- const { findProject, readConfig, writeConfig, newConfig } = require('./project');
10
+ const { findProject, hasConfig, readConfig, writeConfig, newConfig } = require('./project');
11
11
  const { hostOf, apiFor, makeConfirm, makeAskLine, askHidden } = require('./ui');
12
12
 
13
13
  const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
@@ -224,7 +224,7 @@ async function ensurePlan(api, acct) {
224
224
 
225
225
  const started = await call('/start-free').catch(() => null);
226
226
  if (!started || !started.r.ok) return null; // the create below says why
227
- console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, 1 app (any size), 1 GB storage.`);
227
+ console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, any number of apps at any size, 1 GB storage.`);
228
228
  console.log(` Upgrade any time at ${api}/`);
229
229
  return 'free';
230
230
  }
@@ -274,27 +274,78 @@ function sizeList(catalog) {
274
274
  return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
275
275
  }
276
276
 
277
+ // A project's package.json must not be named "nukebase": npm resolves
278
+ // `npx nukebase` to the current project when the names match, and a project
279
+ // has no executable, so every command inside it fails with "could not
280
+ // determine executable to run". Older server templates shipped that name;
281
+ // the project's own name (a valid npm name) replaces it. Returns true when
282
+ // it changed something.
283
+ function namePackage(dir, name) {
284
+ const pkgPath = path.join(dir, 'package.json');
285
+ try {
286
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
287
+ if (pkg.name !== 'nukebase' && pkg.name !== 'nukebase-app') return false;
288
+ pkg.name = name;
289
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
290
+ return true;
291
+ } catch { return false; }
292
+ }
293
+
277
294
  // Create `name` on the server and unpack it into `dest`. Returns
278
295
  // { exists: true } when the account already has a project by that name, so
279
296
  // `start` can try the next one; any other failure stops the command.
280
297
  async function createProject({ acct, api, name, tier, dest, install }) {
281
298
  console.log(`\n🚀 Creating ${name}...`);
282
- let res;
299
+ const headers = (extra = {}) => ({ ...extra, 'X-Project': name, Cookie: cookieFor(acct) });
300
+
301
+ // The connection dropped while the server was setting up. That is not the
302
+ // same as "not created": setup ends with an nginx reload, and a reload
303
+ // sends GOAWAY on an open HTTP/2 connection, which Node's fetch reports as
304
+ // a failed request after the project already exists. (Older servers reload
305
+ // before streaming the ZIP; current ones reload after.) So check, and if it
306
+ // is there, fetch its files the other way rather than leaving a project
307
+ // behind and trying the next name.
308
+ async function recover(err) {
309
+ const why = err.cause?.message || err.message;
310
+ let probe = null;
311
+ try {
312
+ probe = await fetch(`${api}/sync`, {
313
+ method: 'POST',
314
+ headers: headers({ 'Content-Type': 'application/json' }),
315
+ body: JSON.stringify({ exclude: [], files: {}, direction: 'pull', symlinks: false })
316
+ });
317
+ } catch {}
318
+ if (!probe || probe.status === 404) {
319
+ fail(`Create failed: ${why}. Check the dashboard before retrying; it may have been created.`);
320
+ }
321
+ if (!probe.ok) fail(`Create failed: ${why} (and the server answered ${probe.status} when asked whether ${name} exists).`);
322
+ console.log(` (the connection dropped while the server finished setting up ${name} — it exists, downloading it)`);
323
+ let dl;
324
+ try {
325
+ dl = await fetch(`${api}/download-project?project=${encodeURIComponent(name)}`, { headers: headers() });
326
+ } catch (e) {
327
+ fail(`${name} was created but could not be downloaded (${e.cause?.message || e.message}). Link to it: nukebase link ${name}`);
328
+ }
329
+ if (!dl.ok) fail(`${name} was created but could not be downloaded (${dl.status}). Link to it: nukebase link ${name}`);
330
+ return Buffer.from(await dl.arrayBuffer());
331
+ }
332
+
333
+ let res = null, zip = null;
283
334
  try {
284
335
  res = await fetch(`${api}/project`, {
285
336
  method: 'POST',
286
- headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
337
+ headers: headers({ 'Content-Type': 'application/json' }),
287
338
  body: JSON.stringify({ tier })
288
339
  });
289
340
  } catch (e) {
290
- fail(`Create failed: ${e.cause?.message || e.message}. Check the dashboard before retrying; it may have been created.`);
341
+ zip = await recover(e);
291
342
  }
292
- if (!res.ok) {
343
+ if (res && !res.ok) {
293
344
  let msg = '';
294
345
  try { msg = (await res.json()).error; } catch {}
295
346
  if (res.status === 400 && /already exists/i.test(msg || '')) return { exists: true };
296
347
  if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
297
- // No plan, no credit, or the free plan's one project: all fixed on the dashboard.
348
+ // No plan or no credit: fixed on the dashboard.
298
349
  if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
299
350
  fail(`${msg}\n Manage your plan at ${api}/`);
300
351
  }
@@ -302,18 +353,25 @@ async function createProject({ acct, api, name, tier, dest, install }) {
302
353
  }
303
354
 
304
355
  // The server finishes setup as the ZIP finishes streaming, so read it all.
305
- const zip = Buffer.from(await res.arrayBuffer());
356
+ // A drop here is the same case as above.
357
+ if (!zip) {
358
+ try { zip = Buffer.from(await res.arrayBuffer()); }
359
+ catch (e) { zip = await recover(e); }
360
+ }
306
361
  fs.mkdirSync(dest, { recursive: true });
307
362
  const count = unzip.extract(zip, dest);
308
363
 
309
364
  let base = {};
310
365
  try { base = readConfig(dest); } catch {}
311
366
  writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
367
+ namePackage(dest, name);
312
368
  console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
313
369
 
314
370
  if (install && fs.existsSync(path.join(dest, 'package.json'))) {
315
371
  console.log('\n📦 npm install\n');
316
- const r = spawnSync('npm', ['install'], { cwd: dest, stdio: 'inherit', shell: process.platform === 'win32' });
372
+ // One command string under a shell: npm is npm.cmd on Windows, which
373
+ // needs a shell, and Node 26 deprecates passing separate args with one.
374
+ const r = spawnSync('npm install', [], { cwd: dest, stdio: 'inherit', shell: true });
317
375
  if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
318
376
  }
319
377
  return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
@@ -402,7 +460,16 @@ async function startCommand(args) {
402
460
  const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
403
461
  console.log('\n Welcome to NukeBase.');
404
462
 
405
- let acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
463
+ // With several accounts signed in, ask — creating a project bills the
464
+ // account, so never guess between them. Only interactively: an agent (no
465
+ // terminal) or --yes gets the default, as before.
466
+ let acct = emailArg ? creds.find(emailArg) : null;
467
+ if (!acct && !emailArg) {
468
+ const list = creds.list();
469
+ acct = (list.length > 1 && process.stdin.isTTY && !args.has('yes'))
470
+ ? await chooseAccount(args, null, false)
471
+ : (creds.getDefault() || list[0]);
472
+ }
406
473
  if (!acct) {
407
474
  console.log('\n Confirm your Claude session: click Confirm in the browser window that opens.');
408
475
  await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
@@ -469,7 +536,7 @@ async function linkCommand(args) {
469
536
 
470
537
  const root = process.cwd();
471
538
  let base = {};
472
- const existing = fs.existsSync(path.join(root, 'sys', 'config.json'));
539
+ const existing = hasConfig(root);
473
540
  if (existing) {
474
541
  try { base = readConfig(root); } catch {}
475
542
  if (base.uid && base.uid !== acct.uid && !(await makeConfirm(autoYes)(`This folder belongs to ${base.account || base.uid}. Link it to ${acct.label} instead? (y/n) `))) {
@@ -483,6 +550,7 @@ async function linkCommand(args) {
483
550
  }
484
551
 
485
552
  module.exports = {
553
+ namePackage,
486
554
  login: loginCommand,
487
555
  logout: logoutCommand,
488
556
  accounts: accountsCommand,
@@ -1,5 +1,5 @@
1
1
  // Commands that act on the project in the current folder: push, pull, update,
2
- // rollback, backup, env, pause/restart/delete, domain, logs, download and
2
+ // rollback, backup, env, pause/restart/resume/delete, domain, logs, download and
3
3
  // regiontransfer. Loaded by bin/nukebase.js, which runs the command named in
4
4
  // process.argv when this module is required.
5
5
  const { existsSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmdirSync } = require('fs');
@@ -15,7 +15,7 @@ const tar = require('tar');
15
15
  const createClient = require('./sdk');
16
16
  const creds = require('./credentials');
17
17
  const loginFlows = require('./login');
18
- const { findProject, readConfig, writeConfig, migrateLegacy } = require('./project');
18
+ const { findProject, readConfig, writeConfig, restoreSettings, migrateLegacy } = require('./project');
19
19
  const { regionApi, hostOf } = require('./ui');
20
20
 
21
21
  const args = require('./args').parse(process.argv.slice(2));
@@ -28,18 +28,17 @@ const BIN = require.resolve('../bin/nukebase.js');
28
28
 
29
29
  const root = findProject(process.cwd());
30
30
  if (!root) {
31
- console.error('Not inside a NukeBase project (no sys/config.json here or above).');
31
+ console.error('Not inside a NukeBase project (no package.json with a "nukebase" section here or above).');
32
32
  console.error(' nukebase create <name> make a new one');
33
33
  console.error(' nukebase link <name> connect this folder to an existing one');
34
34
  process.exit(1);
35
35
  }
36
- const configPath = join(root, 'sys', 'config.json');
37
36
  let config = readConfig(root);
38
37
  let { project, exclude, API } = config;
39
38
  let uid = null, token = null, accountLabel = null;
40
39
 
41
40
  // `--region <name>` / `--api <url>` point THIS invocation at another region
42
- // without touching sys/config.json. The account is the same everywhere (auth
41
+ // without touching package.json. The account is the same everywhere (auth
43
42
  // replicates), the project is not — so this is how a command reaches a copy
44
43
  // of the project elsewhere: `nukebase delete --region usw3` after a transfer,
45
44
  // or the transfer itself driving the target. A region's API is its name as
@@ -58,7 +57,7 @@ const AS = args.get('as');
58
57
  // ---------------------------------------------------------------------------
59
58
  // The backup set
60
59
  // ---------------------------------------------------------------------------
61
- // sys/config.json carries two path lists. They are INDEPENDENT — each does
60
+ // package.json carries two path lists. They are INDEPENDENT — each does
62
61
  // exactly one job, and neither implies the other:
63
62
  //
64
63
  // exclude what push and pull skip. Nothing else reads it.
@@ -101,7 +100,7 @@ const backupPaths = Array.isArray(config.backup) && config.backup.length
101
100
  // `nukebase rollback backup` — the word "backup" always names the same set.
102
101
  const INCLUDE_BACKUP = args.get('include') === 'backup';
103
102
 
104
- // `.env` is never part of a code push or pull, whatever sys/config.json says.
103
+ // `.env` is never part of a code push or pull, whatever package.json says.
105
104
  // It is the input to `nukebase env push`, which carries it to a root-owned file
106
105
  // OUTSIDE the project on the server. An ordinary push copying it into the
107
106
  // project tree would put secrets where the ZIP download and every restore
@@ -132,7 +131,7 @@ const localExclude = INCLUDE_BACKUP
132
131
  // idle, 520 Mbps with four at once. So a large transfer is split into STREAMS
133
132
  // groups of roughly equal size that move concurrently.
134
133
  //
135
- // sys/config.json "streams": 4 (default, 1-8)
134
+ // package.json "streams": 4 (default, 1-8)
136
135
  // --streams <n> override for one command; --streams 1 is the old behaviour
137
136
  //
138
137
  // Small transfers (under PARALLEL_MIN_BYTES and PARALLEL_MIN_FILES) stay one
@@ -325,7 +324,7 @@ async function connectAndLogin() {
325
324
  ({ uid, token } = acct);
326
325
  accountLabel = acct.label || acct.uid;
327
326
  } else {
328
- if (!config.uid) die('sys/config.json has no "uid", so this folder has no owner.', 'Run: nukebase link <project>');
327
+ if (!config.uid) die('The "nukebase" section of package.json has no "uid", so this folder has no owner.', 'Run: nukebase link <project>');
329
328
  const acct = creds.get(config.uid);
330
329
  if (!acct) {
331
330
  die(`This project belongs to ${config.account || 'uid'} (${config.uid}), which isn't signed in on this machine.`,
@@ -659,9 +658,9 @@ function cleanHeaders(headers) {
659
658
  //
660
659
  // Creates it through the same /project the dashboard uses — template files,
661
660
  // nginx block, unit started, project node with a tier — then returns so the
662
- // push proceeds and replaces the template with the local files. `sys` is in
663
- // the default exclude, so the server keeps the sys/config.json /project
664
- // wrote for it (the new region's own mail key); the local one is untouched.
661
+ // push proceeds and replaces the template with the local files, package.json
662
+ // and its settings included. The mail key the new region's easy-login needs
663
+ // is in no file: the platform hands it to the process at every start.
665
664
  //
666
665
  // Size comes from --tier <name>, else a prompt, else the server's default.
667
666
  // The server enforces plan and wallet for that region and its error is
@@ -778,7 +777,7 @@ function askLine(question) {
778
777
  // ---------------------------------------------------------------------------
779
778
  // Every one of these calls the same endpoint the dashboard does, with the same
780
779
  // body, so behaviour cannot drift between the two. They all address the project
781
- // in sys/config.json.
780
+ // in package.json.
782
781
 
783
782
  // POST to a project endpoint; resolves the parsed JSON, exits with the server's
784
783
  // message on failure. A 401 says how to sign back in.
@@ -829,7 +828,7 @@ async function pauseProject() {
829
828
  async function restartProject() {
830
829
  await connectAndLogin();
831
830
  await projectPost('/restart', {}, { label: command });
832
- console.log(command === 'restart' ? `🔄 Restarting ${project}...` : `▶️ Starting ${project}...`);
831
+ console.log(command === 'restart' ? `🔄 Restarting ${project}...` : `▶️ Resuming ${project}...`);
833
832
  if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
834
833
  watchLogs();
835
834
  }
@@ -863,7 +862,7 @@ async function deleteProject() {
863
862
  // Ctrl+C to give up and come back later), then asks the server to verify and
864
863
  // issue the certificate — the slow part, up to a minute at Let's Encrypt.
865
864
  // --email is the cert contact; it defaults to the account email in
866
- // sys/config.json.
865
+ // package.json.
867
866
  async function domainCommand() {
868
867
  await connectAndLogin();
869
868
  const sub = args.positional[0] || null;
@@ -1096,7 +1095,7 @@ async function downloadCommand() {
1096
1095
  // 4 push the backup set up to the target (downtime ends)
1097
1096
  // 5 copy the hosted environment variables
1098
1097
  // 6 custom domain, if there is one — DNS has to move to the target's IP
1099
- // 7 point sys/config.json at the target
1098
+ // 7 point package.json at the target
1100
1099
  //
1101
1100
  // The source is left PAUSED, never deleted: verify the target, then
1102
1101
  // `nukebase delete --region <source>` when satisfied. Base fee and disk keep
@@ -1155,7 +1154,7 @@ async function regionTransfer() {
1155
1154
  // this uid and token; otherwise — or when `--to <email>` says so — another
1156
1155
  // account, signed in on this machine like any other (~/.nukebase). Every
1157
1156
  // target step then runs `--as <email>`, so the source steps keep this
1158
- // identity and sys/config.json is only rewritten at the very end, when the
1157
+ // identity and package.json is only rewritten at the very end, when the
1159
1158
  // folder changes hands.
1160
1159
  let toEmail = args.get('to') && args.get('to').includes('@') ? args.get('to').trim().toLowerCase() : null;
1161
1160
  let targetUid = uid, targetToken = token;
@@ -1253,7 +1252,7 @@ async function regionTransfer() {
1253
1252
  for (const rest of steps.slice(i)) if (rest.args) console.error(` nukebase ${rest.args.join(' ')}`);
1254
1253
  console.error(` nukebase env push --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''} (after writing the variables to .env)`);
1255
1254
  if (customDomain) console.error(` nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1256
- console.error(` then set "API" in sys/config.json to ${targetApi}`);
1255
+ console.error(` then set "api" in the "nukebase" section of package.json to ${targetApi}`);
1257
1256
  process.exit(1);
1258
1257
  }
1259
1258
  }
@@ -1308,7 +1307,7 @@ async function regionTransfer() {
1308
1307
  fresh.account = creds.get(targetUid)?.label || toEmail;
1309
1308
  }
1310
1309
  writeConfig(root, fresh);
1311
- console.log(` ✓ sys/config.json API → ${targetApi}${toEmail ? `, account → ${fresh.account}` : ''}`);
1310
+ console.log(` ✓ package.json "nukebase" api → ${targetApi}${toEmail ? `, account → ${fresh.account}` : ''}`);
1312
1311
 
1313
1312
  const oldAs = toEmail ? ` --as ${accountLabel}` : '';
1314
1313
  console.log(`\n✅ "${project}" is running in ${target}: https://${targetUid}-${project}.${targetHost}`);
@@ -1367,7 +1366,7 @@ async function push() {
1367
1366
  if (res.status === 401) authExpired();
1368
1367
 
1369
1368
  // The project is not on this server. That is the normal state when the API
1370
- // in sys/config.json was just pointed at a different region — the account
1369
+ // in package.json was just pointed at a different region — the account
1371
1370
  // is the same everywhere, the project is not — so offer to create it here
1372
1371
  // and carry on, rather than failing with a bare 404.
1373
1372
  if (res.status === 404) {
@@ -1618,6 +1617,12 @@ async function pull() {
1618
1617
  const pullSizes = {};
1619
1618
  const result = await downloadStreams(root, download, (f) => pullSizes[f] || 0, totalSize);
1620
1619
 
1620
+ // The server's package.json carries whichever settings were last pushed
1621
+ // to it — from another machine, or with --region another region's copy.
1622
+ // This folder's own settings are what the developer meant; put them back
1623
+ // (also after a stop, since the file may already have landed).
1624
+ if (download.includes('package.json')) restoreSettings(root, config);
1625
+
1621
1626
  if (result.stopped) {
1622
1627
  console.log('\n🛑 Pull stopped cleanly. Local file deletes were skipped. Re-run `nukebase pull` to resume.');
1623
1628
  return;
@@ -1810,6 +1815,9 @@ async function update() {
1810
1815
  }
1811
1816
 
1812
1817
  if (!toWrite.length && !hasPackageWork(pkgPlan)) {
1818
+ if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1819
+ console.log(`\n✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1820
+ }
1813
1821
  console.log('\n✓ Engine is already up to date');
1814
1822
  process.exit(0);
1815
1823
  }
@@ -1819,7 +1827,7 @@ async function update() {
1819
1827
  const pkgCount = pkgPlan.changes.length + (pkgPlan.versionChanged ? 1 : 0);
1820
1828
  console.log(`\n📊 Total: ${outdated} to update, ${added} new, ${pkgCount} package.json change(s)`);
1821
1829
  console.log(' Local edits to these files will be overwritten — add a path to');
1822
- console.log(' "updateExclude" in sys/config.json to protect it permanently');
1830
+ console.log(' "updateExclude" in the "nukebase" section of package.json to protect it permanently');
1823
1831
  console.log(' ("package.json" there opts out of dependency pinning).');
1824
1832
 
1825
1833
  const ok = await confirm(`Continue? (y/n) `);
@@ -1839,6 +1847,12 @@ async function update() {
1839
1847
  applyPackageChanges(pkgPlan);
1840
1848
  console.log(` ✓ package.json (${pkgCount} change(s))`);
1841
1849
  }
1850
+ // Projects from older templates are named "nukebase", which breaks
1851
+ // `npx nukebase` inside them (see namePackage). Repaired here as well, so
1852
+ // one `nukebase update` fixes an existing project.
1853
+ if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1854
+ console.log(` ✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1855
+ }
1842
1856
 
1843
1857
  console.log(`\n✅ Updated ${toWrite.length} file(s) to ${manifest.version}`);
1844
1858
  if (pkgPlan.changes.length) console.log(' → run: npm install');
@@ -2329,9 +2343,9 @@ if (command === 'push' && args.positional[0] === 'env') {
2329
2343
  run(backup);
2330
2344
  } else if (command === 'pause') {
2331
2345
  run(pauseProject);
2332
- } else if (command === 'restart' || command === 'start' || command === 'resume') {
2346
+ } else if (command === 'restart' || command === 'resume') {
2333
2347
  // One endpoint: /restart launches a stopped unit and bounces a running one,
2334
- // so start/resume are the same call under the name the user reaches for.
2348
+ // so resume is the same call under the name the user reaches for.
2335
2349
  run(restartProject);
2336
2350
  } else if (command === 'delete') {
2337
2351
  run(deleteProject);
package/lib/project.js CHANGED
@@ -1,38 +1,133 @@
1
- // A project folder: finding it, its sys/config.json, and moving sign-ins left
2
- // there by the old per-project CLI into ~/.nukebase.
1
+ // A project folder: finding it and its settings.
3
2
  //
4
- // sys/config.json now holds only non-secret settings: API, uid (which account
5
- // owns the project), project, account (that account's email or username, for
6
- // messages), and the exclude/backup/updateExclude/streams settings.
3
+ // Settings live under the "nukebase" key of package.json. Every project has
4
+ // one, `nukebase update` already rewrites it, and npm leaves keys it does not
5
+ // know alone. They hold no account sign-in — only api (the region), uid (which
6
+ // account owns the project), project, account (that account's email, for
7
+ // messages), mailKey (the project's key for hosted sign-in emails, read by the
8
+ // easy-login extension) and the exclude/backup/updateExclude/streams settings.
9
+ // Tokens live in
10
+ // ~/.nukebase/credentials.json (see credentials.js). Push and pull carry
11
+ // package.json, so a fresh clone of a project is already linked.
12
+ //
13
+ // Projects made before 2.1 keep the same settings in sys/config.json. That
14
+ // file is still read, and the first command moves it into package.json. The
15
+ // master project is the exception: its fallback CLI (sys/deploy.js) reads
16
+ // sys/config.json, so there the file is kept and merely stripped of sign-ins.
7
17
 
8
18
  const fs = require('fs');
9
19
  const path = require('path');
10
20
  const creds = require('./credentials');
11
21
  const login = require('./login');
12
22
 
13
- const DEFAULT_EXCLUDE = ['sys', '.env', 'server/data', 'server/backup', 'server/files'];
23
+ const KEY = 'nukebase';
24
+ const DEFAULT_EXCLUDE = ['.env', 'server/data', 'server/backup', 'server/files'];
14
25
  const DEFAULT_BACKUP = ['server/data', 'server/files'];
15
26
  const SECRET_FIELDS = ['token', 'password', 'username', 'email', 'privateKey'];
27
+ // Written first, in this order, so the section reads the same in every project.
28
+ const FIELD_ORDER = ['api', 'uid', 'project', 'account', 'exclude', 'backup', 'updateExclude', 'streams'];
29
+
30
+ const pkgPathOf = (root) => path.join(root, 'package.json');
31
+ const legacyPathOf = (root) => path.join(root, 'sys', 'config.json');
32
+ const isMaster = (root) => fs.existsSync(path.join(root, 'server', 'region.config.json'));
33
+
34
+ function readPkg(root) {
35
+ try {
36
+ const pkg = JSON.parse(fs.readFileSync(pkgPathOf(root), 'utf8'));
37
+ return pkg && typeof pkg === 'object' && !Array.isArray(pkg) ? pkg : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ function pkgSection(root) {
42
+ const pkg = readPkg(root);
43
+ const s = pkg && pkg[KEY];
44
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : null;
45
+ }
46
+
47
+ // Does this folder carry project settings, in either place?
48
+ function hasConfig(dir) {
49
+ return !!pkgSection(dir) || fs.existsSync(legacyPathOf(dir));
50
+ }
51
+
52
+ // Settings only in sys/config.json: they have not moved into package.json yet.
53
+ function isLegacy(root) {
54
+ return !pkgSection(root) && fs.existsSync(legacyPathOf(root));
55
+ }
16
56
 
17
57
  function findProject(dir) {
18
58
  while (dir !== path.dirname(dir)) {
19
- if (fs.existsSync(path.join(dir, 'sys', 'config.json'))) return dir;
59
+ if (hasConfig(dir)) return dir;
20
60
  dir = path.dirname(dir);
21
61
  }
22
62
  return null;
23
63
  }
24
64
 
25
- function configPathOf(root) {
26
- return path.join(root, 'sys', 'config.json');
65
+ // The settings as the commands use them: `API` is the one name every command
66
+ // reads (package.json stores it as `api`); everything else as stored.
67
+ function readConfig(root) {
68
+ const section = pkgSection(root);
69
+ const raw = section || JSON.parse(fs.readFileSync(legacyPathOf(root), 'utf8'));
70
+ const { api, ...rest } = raw;
71
+ return { API: rest.API || api, ...rest };
27
72
  }
28
73
 
29
- function readConfig(root) {
30
- return JSON.parse(fs.readFileSync(configPathOf(root), 'utf8'));
74
+ // The stored form: `api` first, then the known fields in a fixed order, then
75
+ // anything else the project added.
76
+ function toSection(config) {
77
+ const { API, api, ...rest } = config;
78
+ const all = { api: API || api, ...rest };
79
+ const section = {};
80
+ for (const k of FIELD_ORDER) if (all[k] !== undefined) section[k] = all[k];
81
+ for (const [k, v] of Object.entries(all)) if (!(k in section) && v !== undefined) section[k] = v;
82
+ return section;
31
83
  }
32
84
 
85
+ // Write the settings into package.json. A folder without one (`nukebase link`
86
+ // into an empty directory) gets a minimal package.json; a package.json without
87
+ // the section gets it right after "version", where a reader looks first.
88
+ // sys/config.json, if present, goes — except in the master project, where it
89
+ // is rewritten without sign-ins for the fallback CLI that still reads it.
33
90
  function writeConfig(root, config) {
34
- fs.mkdirSync(path.join(root, 'sys'), { recursive: true });
35
- fs.writeFileSync(configPathOf(root), JSON.stringify(config, null, 2) + '\n');
91
+ const section = toSection(config);
92
+ const existing = readPkg(root);
93
+ let pkg;
94
+ if (!existing) {
95
+ pkg = { name: String(section.project || path.basename(root)).toLowerCase(), version: '0.0.0', private: true, [KEY]: section };
96
+ } else if (KEY in existing) {
97
+ pkg = { ...existing, [KEY]: section };
98
+ } else {
99
+ pkg = {};
100
+ const after = 'version' in existing ? 'version' : ('name' in existing ? 'name' : null);
101
+ for (const [k, v] of Object.entries(existing)) {
102
+ pkg[k] = v;
103
+ if (k === after) pkg[KEY] = section;
104
+ }
105
+ if (!after) pkg[KEY] = section;
106
+ }
107
+ fs.mkdirSync(root, { recursive: true });
108
+ fs.writeFileSync(pkgPathOf(root), JSON.stringify(pkg, null, 2) + '\n');
109
+
110
+ const legacy = legacyPathOf(root);
111
+ if (!fs.existsSync(legacy)) return;
112
+ if (isMaster(root)) {
113
+ const clean = { API: section.api, ...section };
114
+ delete clean.api;
115
+ for (const f of SECRET_FIELDS) delete clean[f];
116
+ fs.writeFileSync(legacy, JSON.stringify(clean, null, 2) + '\n');
117
+ return;
118
+ }
119
+ try { fs.unlinkSync(legacy); } catch {}
120
+ try { if (!fs.readdirSync(path.dirname(legacy)).length) fs.rmdirSync(path.dirname(legacy)); } catch {}
121
+ }
122
+
123
+ // After a pull: the server's package.json carries whichever settings were
124
+ // last pushed to it — from another machine, or with --region another region's
125
+ // copy. The settings this folder had before the pull are what the developer
126
+ // meant, so they go back in.
127
+ function restoreSettings(root, config) {
128
+ const pulled = pkgSection(root);
129
+ if (pulled && pulled.mailKey && !config.mailKey) config = { ...config, mailKey: pulled.mailKey };
130
+ writeConfig(root, config);
36
131
  }
37
132
 
38
133
  // The settings a freshly linked or created project gets.
@@ -44,7 +139,7 @@ function newConfig({ api, uid, project, account }, base = {}) {
44
139
  updateExclude: base.updateExclude || [],
45
140
  streams: base.streams || 4
46
141
  };
47
- for (const [k, v] of Object.entries(base)) if (!(k in out) && !SECRET_FIELDS.includes(k)) out[k] = v;
142
+ for (const [k, v] of Object.entries(base)) if (!(k in out) && k !== 'api' && !SECRET_FIELDS.includes(k)) out[k] = v;
48
143
  return out;
49
144
  }
50
145
 
@@ -55,19 +150,29 @@ function sessionFiles(root) {
55
150
  } catch { return []; }
56
151
  }
57
152
 
153
+ // Settings still in sys/config.json move into package.json. Returns the
154
+ // config unchanged; the move is on disk.
155
+ function moveSettings(root, config) {
156
+ if (!isLegacy(root)) return config;
157
+ writeConfig(root, config);
158
+ console.log(`📦 Moved this project's settings from sys/config.json into package.json (its "${KEY}" section).`);
159
+ return config;
160
+ }
161
+
58
162
  // Errors that mean "try again later" rather than "this sign-in is dead": a
59
163
  // server without CLI sign-in yet, or no connection. The old sign-in is kept
60
164
  // and used as before until the move can happen.
61
165
  const isOldServer = (e) => /doesn't support CLI sign-in|Could not reach/.test(e.message);
62
166
 
63
- // Move a project's old sign-in into ~/.nukebase, then strip it from
64
- // sys/config.json. Returns the config to use.
167
+ // Move a project's old sign-in into ~/.nukebase, then strip it from the
168
+ // settings; move the settings themselves into package.json. Returns the
169
+ // config to use.
65
170
  // On a server without CLI sign-in yet, nothing changes and `legacy` is true:
66
171
  // the caller then uses the token from sys/config.json as before.
67
172
  async function migrateLegacy(root, config) {
68
173
  const hasSecrets = SECRET_FIELDS.some(f => config[f]);
69
174
  const sessions = sessionFiles(root);
70
- if (!hasSecrets && !sessions.length) return { config, legacy: false };
175
+ if (!hasSecrets && !sessions.length) return { config: moveSettings(root, config), legacy: false };
71
176
 
72
177
  const api = config.API;
73
178
  const label = config.email || config.username || null;
@@ -113,13 +218,13 @@ async function migrateLegacy(root, config) {
113
218
  const clean = { ...config, account: config.account || label || undefined };
114
219
  for (const f of SECRET_FIELDS) delete clean[f];
115
220
  writeConfig(root, clean);
116
- console.log('🔒 Removed the sign-in (token, password) from sys/config.json. Projects now hold only which account owns them.');
221
+ console.log(`🔒 Removed the sign-in (token, password) from this project's settings. Projects now hold only which account owns them, under "${KEY}" in package.json.`);
117
222
  if (config.uid && !creds.get(config.uid)) {
118
223
  console.log(` This project's account isn't signed in on this machine. Run: nukebase login${label && label.includes('@') ? ` --email ${label}` : ''}`);
119
224
  }
120
225
  return { config: clean, legacy: false };
121
226
  }
122
- return { config, legacy: false };
227
+ return { config: moveSettings(root, config), legacy: false };
123
228
  }
124
229
 
125
- module.exports = { findProject, configPathOf, readConfig, writeConfig, newConfig, migrateLegacy, DEFAULT_EXCLUDE, DEFAULT_BACKUP };
230
+ module.exports = { KEY, findProject, hasConfig, readConfig, writeConfig, restoreSettings, newConfig, migrateLegacy, DEFAULT_EXCLUDE, DEFAULT_BACKUP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.0.6",
3
+ "version": "2.1.1",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"