paratix 0.16.0 → 0.17.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.
@@ -104,6 +104,7 @@ function shellQuote(s) {
104
104
  return `'${s.replaceAll("'", "'\\''")}'`;
105
105
  }
106
106
  var DEFAULT_MAX_OUTPUT_BYTES = Number("1048576");
107
+ var CAPTURE_TRUNCATION_MARKER = "\n[output truncated]";
107
108
  var CommandError = class _CommandError extends Error {
108
109
  /** Full, untruncated standard error of the failed command. */
109
110
  fullStderr;
@@ -1367,6 +1368,17 @@ async function setVersionedFlag(ssh2, flagName, flagPrefix) {
1367
1368
  if (result.code === 0) return null;
1368
1369
  return failedCommand(`[moduleHelpers] failed to persist versioned flag ${flagName}`, result);
1369
1370
  }
1371
+ async function setFlag(ssh2, flagName) {
1372
+ validateFlagName(flagName, "flagName");
1373
+ const ensureFailure = await ensureFlagsDirectory(ssh2);
1374
+ if (ensureFailure) return ensureFailure;
1375
+ const result = await ssh2.exec(`touch ${FLAGS_DIRECTORY}/${shellQuote(flagName)}`, {
1376
+ ignoreExitCode: true,
1377
+ silent: true
1378
+ });
1379
+ if (result.code === 0) return null;
1380
+ return failedCommand(`[moduleHelpers] failed to persist flag ${flagName}`, result);
1381
+ }
1370
1382
  async function applyWithFlagLock(ssh2, parameters) {
1371
1383
  validateFlagName(parameters.flagName, "flagName");
1372
1384
  const lockName = flagLockName(parameters.flagName);
@@ -1473,6 +1485,19 @@ async function tryApplyWithFlagLock(ssh2, parameters) {
1473
1485
  return tryApplyWithFlagLock(ssh2, parameters);
1474
1486
  }
1475
1487
 
1488
+ // src/modules/packageUpgradeSummary.ts
1489
+ var APT_UPGRADE_SUMMARY_PATTERN = new RegExp("^(?<upgraded>\\d+) upgraded, ", "mv");
1490
+ var UNKNOWN_UPGRADE_OUTCOME_DETAIL = "upgrade completed, package count unavailable";
1491
+ function describeAptUpgradeOutcome(stdout) {
1492
+ if (stdout.endsWith(CAPTURE_TRUNCATION_MARKER)) return UNKNOWN_UPGRADE_OUTCOME_DETAIL;
1493
+ const captured = APT_UPGRADE_SUMMARY_PATTERN.exec(stdout)?.groups?.upgraded;
1494
+ if (captured == null || captured === "") return UNKNOWN_UPGRADE_OUTCOME_DETAIL;
1495
+ const upgraded = Number(captured);
1496
+ if (!Number.isSafeInteger(upgraded)) return UNKNOWN_UPGRADE_OUTCOME_DETAIL;
1497
+ if (upgraded === 0) return "no packages upgraded";
1498
+ return upgraded === 1 ? "1 package upgraded" : `${upgraded} packages upgraded`;
1499
+ }
1500
+
1476
1501
  // src/modules/remoteFileChecks.ts
1477
1502
  import { posix as posix3 } from "path";
1478
1503
  async function isRegularFileWithoutSymlink(ssh2, remotePath) {
@@ -1945,6 +1970,12 @@ var apt = {
1945
1970
  * The pipeline is split into three separate SSH commands so that each step
1946
1971
  * gets its own timeout window and produces a precise failure label.
1947
1972
  *
1973
+ * The marker is written with `setFlag`: the flag name carries only the
1974
+ * date and no call-site identity, so an evicting prefix would be
1975
+ * host-global and two `apt.distUpgrade` calls with different dates would
1976
+ * delete each other's marker on every run. Retired markers are therefore
1977
+ * not pruned, and re-using an earlier date is skipped rather than re-run.
1978
+ *
1948
1979
  * @param date - A date string used as the idempotency key (e.g. `"2024-01-15"`).
1949
1980
  * @param options - Optional per-call overrides (e.g. SSH command `timeout`).
1950
1981
  * The same `timeout` is applied to every step of the dist-upgrade pipeline.
@@ -1977,9 +2008,9 @@ var apt = {
1977
2008
  );
1978
2009
  if (upgrade.code !== 0)
1979
2010
  return failedCommand("[apt.distUpgrade] apt-get dist-upgrade failed", upgrade);
1980
- const flagFailure = await setVersionedFlag(ssh2, flagName, "apt-dist-upgrade-");
2011
+ const flagFailure = await setFlag(ssh2, flagName);
1981
2012
  if (flagFailure) return flagFailure;
1982
- return { status: "changed" };
2013
+ return { detail: describeAptUpgradeOutcome(upgrade.stdout), status: "changed" };
1983
2014
  },
1984
2015
  flagName
1985
2016
  });
@@ -2519,6 +2550,13 @@ async function validateExistingExtractDestination(conn, parameters) {
2519
2550
  // src/modules/fileMetadataHelpers.ts
2520
2551
  var EXEC_OPTS2 = { ignoreExitCode: true, silent: true };
2521
2552
  var DEFAULT_FILE_WRITE_MODE = "0644";
2553
+ var EMPTY_OWNERSHIP = {
2554
+ group: "",
2555
+ groupId: "",
2556
+ mode: "",
2557
+ owner: "",
2558
+ ownerId: ""
2559
+ };
2522
2560
  function assertValidChownOwnershipSpec(ownerSpec) {
2523
2561
  if (ownerSpec === "") {
2524
2562
  throw new Error("chown ownership spec must not be empty");
@@ -2581,20 +2619,23 @@ function renderGuardedMetadataCommand(kind, remotePath, value) {
2581
2619
  ].join("\n");
2582
2620
  }
2583
2621
  async function readOwnership(ssh2, remotePath) {
2584
- const result = await ssh2.exec(`stat -c '%a %U %G' ${shellQuote(remotePath)}`, EXEC_OPTS2);
2585
- if (result.code !== 0) return { group: "", mode: "", owner: "" };
2586
- const [mode = "", owner = "", group2 = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
2587
- return { group: group2, mode, owner };
2622
+ const result = await ssh2.exec(`stat -c '%a %U %G %u %g' ${shellQuote(remotePath)}`, EXEC_OPTS2);
2623
+ if (result.code !== 0) return { ...EMPTY_OWNERSHIP };
2624
+ const [mode = "", owner = "", group2 = "", ownerId = "", groupId = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
2625
+ return { group: group2, groupId, mode, owner, ownerId };
2588
2626
  }
2589
2627
  function normalizeMode(mode) {
2590
2628
  return mode.startsWith("0") ? mode : `0${mode}`;
2591
2629
  }
2592
- function ownershipComponentMatches(expected, actual) {
2593
- return expected === "" || actual === expected;
2630
+ var NUMERIC_ID_PATTERN = new RegExp("^\\d+$", "v");
2631
+ function isNumericId(value) {
2632
+ return NUMERIC_ID_PATTERN.test(value);
2594
2633
  }
2595
- function groupOwnershipMatches(expectsGroup, expectedGroup, actualGroup) {
2596
- if (!expectsGroup) return true;
2597
- return ownershipComponentMatches(expectedGroup, actualGroup);
2634
+ function ownershipComponentMatches(expected, actual, actualId) {
2635
+ if (expected === "") return true;
2636
+ if (actual === expected || actualId === expected) return true;
2637
+ if (!isNumericId(expected) || !isNumericId(actualId)) return false;
2638
+ return Number(expected) === Number(actualId);
2598
2639
  }
2599
2640
  async function resolveWriteMode(ssh2, remotePath, explicitMode) {
2600
2641
  if (explicitMode != null) return explicitMode;
@@ -2635,10 +2676,10 @@ function ownershipMatches(current, options) {
2635
2676
  if (options?.mode != null && current.mode !== options.mode.replace(new RegExp("^0+", "v"), "")) return false;
2636
2677
  if (options?.owner == null) return true;
2637
2678
  const expectsGroup = options.owner.includes(":");
2638
- const [expectedOwner, expectedGroup = ""] = options.owner.split(":", 2);
2639
- if (!ownershipComponentMatches(expectedOwner, current.owner)) return false;
2640
- if (!groupOwnershipMatches(expectsGroup, expectedGroup, current.group)) return false;
2641
- return true;
2679
+ const [expectedOwner = "", expectedGroup = ""] = options.owner.split(":", 2);
2680
+ if (!ownershipComponentMatches(expectedOwner, current.owner, current.ownerId)) return false;
2681
+ if (!expectsGroup) return true;
2682
+ return ownershipComponentMatches(expectedGroup, current.group, current.groupId);
2642
2683
  }
2643
2684
  function createMetadataModule(kind, remotePath, value) {
2644
2685
  const name = `file.${kind}: ${remotePath}`;
@@ -2676,6 +2717,7 @@ var SILENT = { silent: true };
2676
2717
  var FLAGS_DIR = "/var/lib/paratix/flags";
2677
2718
  var ARCHIVE_MARKER_MODE = "0644";
2678
2719
  var ARCHIVE_OWNER_MEMBER_CONCURRENCY = 8;
2720
+ var ARCHIVE_STAT_OWNERSHIP_FIELDS = 4;
2679
2721
  async function mapWithConcurrencyLimit2(items, limit, mapper) {
2680
2722
  if (items.length === 0) return [];
2681
2723
  const results = [];
@@ -3055,11 +3097,9 @@ async function applyExtract(conn, parameters) {
3055
3097
  }
3056
3098
  }
3057
3099
  function ownerMatchesStat(stdout, owner) {
3058
- const [actualUser = "", actualGroup = ""] = stdout.trim().split(new RegExp("\\s+", "v"), 2);
3100
+ const [actualUser = "", actualGroup = "", actualUserId = "", actualGroupId = ""] = stdout.trim().split(new RegExp("\\s+", "v"), ARCHIVE_STAT_OWNERSHIP_FIELDS);
3059
3101
  const [expectedUser = "", expectedGroup = ""] = owner.split(":", 2);
3060
- if (expectedUser !== "" && actualUser !== expectedUser) return false;
3061
- if (expectedGroup !== "" && actualGroup !== expectedGroup) return false;
3062
- return true;
3102
+ return ownershipComponentMatches(expectedUser, actualUser, actualUserId) && ownershipComponentMatches(expectedGroup, actualGroup, actualGroupId);
3063
3103
  }
3064
3104
  function isStringArray(value) {
3065
3105
  return Array.isArray(value) && value.every((item) => typeof item === "string");
@@ -3071,7 +3111,7 @@ async function extractedMemberOwnerMatches(conn, parameters) {
3071
3111
  EXEC_OPTS3
3072
3112
  );
3073
3113
  if (exists.code !== 0) return false;
3074
- const stat5 = await conn.exec(`stat -c '%U %G' -- ${shellQuote(path3)}`, EXEC_OPTS3);
3114
+ const stat5 = await conn.exec(`stat -c '%U %G %u %g' -- ${shellQuote(path3)}`, EXEC_OPTS3);
3075
3115
  if (stat5.code !== 0) return false;
3076
3116
  return ownerMatchesStat(stat5.stdout, owner);
3077
3117
  }
@@ -5354,18 +5394,22 @@ function rejectSensitiveHeadersOverHttp(parameters) {
5354
5394
  );
5355
5395
  }
5356
5396
  async function readDownloadOwnership(conn, destination) {
5357
- const result = await conn.exec(`stat -c '%a %U %G' ${shellQuote(destination)}`, {
5397
+ const result = await conn.exec(`stat -c '%a %U %G %u %g' ${shellQuote(destination)}`, {
5358
5398
  ignoreExitCode: true,
5359
5399
  silent: true
5360
5400
  });
5361
- if (result.code !== 0) return { group: "", mode: "", owner: "" };
5362
- const [mode = "", owner = "", group2 = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
5363
- return { group: group2, mode, owner };
5401
+ if (result.code !== 0) return { group: "", groupId: "", mode: "", owner: "", ownerId: "" };
5402
+ const [mode = "", owner = "", group2 = "", ownerId = "", groupId = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
5403
+ return { group: group2, groupId, mode, owner, ownerId };
5404
+ }
5405
+ function downloadComponentMatches(expected, actual, actualId) {
5406
+ if (expected == null) return true;
5407
+ return ownershipComponentMatches(expected, actual, actualId);
5364
5408
  }
5365
5409
  function downloadOwnershipMatches(current, options) {
5366
5410
  if (options.mode != null && current.mode !== options.mode.replace(new RegExp("^0+", "v"), "")) return false;
5367
- if (options.owner != null && current.owner !== options.owner) return false;
5368
- if (options.group != null && current.group !== options.group) return false;
5411
+ if (!downloadComponentMatches(options.owner, current.owner, current.ownerId)) return false;
5412
+ if (!downloadComponentMatches(options.group, current.group, current.groupId)) return false;
5369
5413
  return true;
5370
5414
  }
5371
5415
  async function metadataMatches(conn, destination, options) {
@@ -5564,7 +5608,7 @@ function downloadModeDrifted(current, options) {
5564
5608
  return options.mode != null && current.mode !== options.mode.replace(new RegExp("^0+", "v"), "");
5565
5609
  }
5566
5610
  function downloadOwnerDrifted(current, options) {
5567
- return options.owner != null && current.owner !== options.owner || options.group != null && current.group !== options.group;
5611
+ return !downloadComponentMatches(options.owner, current.owner, current.ownerId) || !downloadComponentMatches(options.group, current.group, current.groupId);
5568
5612
  }
5569
5613
  async function healModeDrift(conn, parameters, current) {
5570
5614
  if (parameters.mode == null || !downloadModeDrifted(current, parameters)) {
@@ -6922,18 +6966,27 @@ ${endMarker}`
6922
6966
  };
6923
6967
  }
6924
6968
  async function readPropertiesState(ssh2, remotePath) {
6925
- const result = await ssh2.exec(`stat -c '%a %U %G' ${shellQuote(remotePath)}`, EXEC_OPTS6);
6926
- if (result.code !== 0) return { group: "", mode: "", owner: "" };
6927
- const [mode = "", owner = "", group2 = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
6928
- return { group: group2, mode, owner };
6969
+ const result = await ssh2.exec(`stat -c '%a %U %G %u %g' ${shellQuote(remotePath)}`, EXEC_OPTS6);
6970
+ if (result.code !== 0) return { group: "", groupId: "", mode: "", owner: "", ownerId: "" };
6971
+ const [mode = "", owner = "", group2 = "", ownerId = "", groupId = ""] = result.stdout.trim().split(new RegExp("\\s+", "v"));
6972
+ return { group: group2, groupId, mode, owner, ownerId };
6929
6973
  }
6930
6974
  function modeMatches(current, desired) {
6931
6975
  return current === desired.replace(new RegExp("^0+", "v"), "");
6932
6976
  }
6977
+ function assertValidOwnershipComponent(value, kind) {
6978
+ if (isNumericId(value)) return;
6979
+ if (kind === "user") assertValidUserName(value);
6980
+ else assertValidGroupName(value);
6981
+ }
6982
+ function propertiesComponentMatches(expected, actual, actualId) {
6983
+ if (expected == null) return true;
6984
+ return ownershipComponentMatches(expected, actual, actualId);
6985
+ }
6933
6986
  function assertValidPropertiesOptions(options) {
6934
6987
  if (options.mode != null) validateMode(options.mode);
6935
- if (options.owner != null) assertValidUserName(options.owner);
6936
- if (options.group != null) assertValidGroupName(options.group);
6988
+ if (options.owner != null) assertValidOwnershipComponent(options.owner, "user");
6989
+ if (options.group != null) assertValidOwnershipComponent(options.group, "group");
6937
6990
  }
6938
6991
  function isDriftFailure(result) {
6939
6992
  return typeof result !== "boolean";
@@ -6949,8 +7002,8 @@ async function applyModeDrift(context) {
6949
7002
  }
6950
7003
  async function maybeApplyCombinedChown(context) {
6951
7004
  const { current, options, remotePath, ssh: ssh2 } = context;
6952
- const ownerNeedsUpdate = options.owner != null && current.owner !== options.owner;
6953
- const groupNeedsUpdate = options.group != null && current.group !== options.group;
7005
+ const ownerNeedsUpdate = options.owner != null && !propertiesComponentMatches(options.owner, current.owner, current.ownerId);
7006
+ const groupNeedsUpdate = options.group != null && !propertiesComponentMatches(options.group, current.group, current.groupId);
6954
7007
  if (!ownerNeedsUpdate || !groupNeedsUpdate || options.owner == null || options.group == null) {
6955
7008
  return false;
6956
7009
  }
@@ -6963,7 +7016,9 @@ async function maybeApplyCombinedChown(context) {
6963
7016
  }
6964
7017
  async function maybeApplySingleChown(context) {
6965
7018
  const { current, options, remotePath, ssh: ssh2 } = context;
6966
- if (options.owner == null || current.owner === options.owner) return false;
7019
+ if (options.owner == null || propertiesComponentMatches(options.owner, current.owner, current.ownerId)) {
7020
+ return false;
7021
+ }
6967
7022
  const result = await ssh2.exec(renderGuardedChownCommand(options.owner, remotePath), EXEC_OPTS6);
6968
7023
  if (result.code !== 0) {
6969
7024
  return failedCommand(`[file.properties: ${remotePath}] chown failed`, result);
@@ -6972,7 +7027,9 @@ async function maybeApplySingleChown(context) {
6972
7027
  }
6973
7028
  async function maybeApplySingleChgrp(context) {
6974
7029
  const { current, options, remotePath, ssh: ssh2 } = context;
6975
- if (options.group == null || current.group === options.group) return false;
7030
+ if (options.group == null || propertiesComponentMatches(options.group, current.group, current.groupId)) {
7031
+ return false;
7032
+ }
6976
7033
  const result = await ssh2.exec(renderGuardedChgrpCommand(options.group, remotePath), EXEC_OPTS6);
6977
7034
  if (result.code !== 0) {
6978
7035
  return failedCommand(`[file.properties: ${remotePath}] chgrp failed`, result);
@@ -7011,10 +7068,10 @@ function properties(remotePath, options) {
7011
7068
  async check(ssh2) {
7012
7069
  if (!ssh2) return NEEDS_APPLY;
7013
7070
  if (await isSymlink(ssh2, remotePath)) return NEEDS_APPLY;
7014
- const { group: group2, mode, owner } = await readPropertiesState(ssh2, remotePath);
7071
+ const { group: group2, groupId, mode, owner, ownerId } = await readPropertiesState(ssh2, remotePath);
7015
7072
  if (options.mode != null && !modeMatches(mode, options.mode)) return NEEDS_APPLY;
7016
- if (options.owner != null && owner !== options.owner) return NEEDS_APPLY;
7017
- if (options.group != null && group2 !== options.group) return NEEDS_APPLY;
7073
+ if (!propertiesComponentMatches(options.owner, owner, ownerId)) return NEEDS_APPLY;
7074
+ if (!propertiesComponentMatches(options.group, group2, groupId)) return NEEDS_APPLY;
7018
7075
  return "ok";
7019
7076
  },
7020
7077
  name: `file.properties: ${remotePath}`
@@ -10689,6 +10746,7 @@ async function runVersionedInstall(parameters) {
10689
10746
 
10690
10747
  // src/modules/package.ts
10691
10748
  var EXEC_OPTS12 = { ignoreExitCode: true, silent: true };
10749
+ var PACKAGE_INDEX_REFRESHED_DETAIL = "marker was missing, package index refreshed";
10692
10750
  function execOptions(options) {
10693
10751
  if (options?.timeout === void 0) return EXEC_OPTS12;
10694
10752
  return { ...EXEC_OPTS12, timeout: options.timeout };
@@ -10920,7 +10978,16 @@ var pkg = {
10920
10978
  * A flag file at `${FLAGS_DIRECTORY}/package-update-<date>` is created after
10921
10979
  * a successful run. On the next run the flag is detected and the module
10922
10980
  * reports `"ok"` without running the update again. Changing `date` to a new
10923
- * value invalidates all previous flags for this operation.
10981
+ * value selects a new flag file, so the update runs once more.
10982
+ *
10983
+ * The marker is written with `setFlag`, not `setVersionedFlag`: the flag
10984
+ * name carries no call-site identity, only the date. An evicting prefix
10985
+ * would therefore be host-global, and two `package.update` calls with
10986
+ * different dates would delete each other's marker on every run so that
10987
+ * neither could ever converge. Two consequences are accepted deliberately:
10988
+ * markers for retired dates are not pruned (one empty file per distinct
10989
+ * date), and re-using an earlier date is skipped rather than re-run,
10990
+ * because that marker still exists.
10924
10991
  *
10925
10992
  * @param date - A date string used as the idempotency key (e.g. `"2024-01-15"`).
10926
10993
  * @param options - Optional per-call overrides (e.g. SSH command `timeout`).
@@ -10942,9 +11009,9 @@ var pkg = {
10942
11009
  if (result.code !== 0) {
10943
11010
  return failedCommand(`[package.update: ${date}] package index refresh failed`, result);
10944
11011
  }
10945
- const flagFailure = await setVersionedFlag(ssh2, flagName, "package-update-");
11012
+ const flagFailure = await setFlag(ssh2, flagName);
10946
11013
  if (flagFailure) return flagFailure;
10947
- return { status: "changed" };
11014
+ return { detail: PACKAGE_INDEX_REFRESHED_DETAIL, status: "changed" };
10948
11015
  },
10949
11016
  async check(ssh2) {
10950
11017
  if (!ssh2) return NEEDS_APPLY;
@@ -10959,7 +11026,13 @@ var pkg = {
10959
11026
  * A flag file at `${FLAGS_DIRECTORY}/package-upgrade-<date>` is created after
10960
11027
  * a successful run. On the next run the flag is detected and the module
10961
11028
  * reports `"ok"` without running the upgrade again. Changing `date` to a new
10962
- * value invalidates all previous flags for this operation.
11029
+ * value selects a new flag file, so the upgrade runs once more.
11030
+ *
11031
+ * The marker is written with `setFlag` for the same reason as in
11032
+ * `pkg.update`: the flag name carries only the date, so an
11033
+ * evicting prefix would be host-global and sibling calls could never
11034
+ * converge. Retired markers are therefore not pruned, and re-using an
11035
+ * earlier date is skipped rather than re-run.
10963
11036
  *
10964
11037
  * On apt systems this runs `dpkg --configure -a`, `apt-get update`, and
10965
11038
  * `apt-get upgrade -y` as three separate commands (each subject to its own
@@ -10985,15 +11058,20 @@ var pkg = {
10985
11058
  const pm = await detectPackageManager(ssh2);
10986
11059
  if (!pm) return missingPackageManager(`package.upgrade: ${date}`);
10987
11060
  const pipelineOptions = execOptions(options);
11061
+ let lastStdout = "";
10988
11062
  for (const command2 of UPGRADE_COMMANDS[pm]) {
10989
11063
  const result = await ssh2.exec(command2, pipelineOptions);
10990
11064
  if (result.code !== 0) {
10991
11065
  return failedCommand(`[package.upgrade: ${date}] package upgrade failed`, result);
10992
11066
  }
11067
+ lastStdout = result.stdout;
10993
11068
  }
10994
- const flagFailure = await setVersionedFlag(ssh2, flagName, "package-upgrade-");
11069
+ const flagFailure = await setFlag(ssh2, flagName);
10995
11070
  if (flagFailure) return flagFailure;
10996
- return { status: "changed" };
11071
+ return {
11072
+ detail: pm === "apt" ? describeAptUpgradeOutcome(lastStdout) : UNKNOWN_UPGRADE_OUTCOME_DETAIL,
11073
+ status: "changed"
11074
+ };
10997
11075
  },
10998
11076
  async check(ssh2) {
10999
11077
  if (!ssh2) return NEEDS_APPLY;
package/dist/cli.js CHANGED
@@ -6262,7 +6262,7 @@ async function runApplyCommand(file, options, run = runPlaybook) {
6262
6262
  if (options.diff && !options.dryRun) {
6263
6263
  throw new CliUsageError("--diff requires --dry-run");
6264
6264
  }
6265
- printCliHeader("0.16.0-81f051d");
6265
+ printCliHeader("0.17.0-a7265db");
6266
6266
  const environmentOverrides = applyCliEnvironmentOverrides(options.env, {
6267
6267
  firstRun: options.firstRun
6268
6268
  });
@@ -6300,7 +6300,7 @@ function renderSubcommandOptionsHelp(command) {
6300
6300
  return ["", `Options for "paratix ${command.name()} <file>":`, ...rows].join("\n");
6301
6301
  }
6302
6302
  var program = new Command();
6303
- program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.16.0-81f051d");
6303
+ program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.17.0-a7265db");
6304
6304
  var applyCommand = program.command("apply <file>").description("Apply a server definition").option(
6305
6305
  "--diff",
6306
6306
  "When combined with --dry-run, show a unified diff per module that would change.",
@@ -429,7 +429,16 @@ declare const pkg: {
429
429
  * A flag file at `${FLAGS_DIRECTORY}/package-update-<date>` is created after
430
430
  * a successful run. On the next run the flag is detected and the module
431
431
  * reports `"ok"` without running the update again. Changing `date` to a new
432
- * value invalidates all previous flags for this operation.
432
+ * value selects a new flag file, so the update runs once more.
433
+ *
434
+ * The marker is written with `setFlag`, not `setVersionedFlag`: the flag
435
+ * name carries no call-site identity, only the date. An evicting prefix
436
+ * would therefore be host-global, and two `package.update` calls with
437
+ * different dates would delete each other's marker on every run so that
438
+ * neither could ever converge. Two consequences are accepted deliberately:
439
+ * markers for retired dates are not pruned (one empty file per distinct
440
+ * date), and re-using an earlier date is skipped rather than re-run,
441
+ * because that marker still exists.
433
442
  *
434
443
  * @param date - A date string used as the idempotency key (e.g. `"2024-01-15"`).
435
444
  * @param options - Optional per-call overrides (e.g. SSH command `timeout`).
@@ -446,7 +455,13 @@ declare const pkg: {
446
455
  * A flag file at `${FLAGS_DIRECTORY}/package-upgrade-<date>` is created after
447
456
  * a successful run. On the next run the flag is detected and the module
448
457
  * reports `"ok"` without running the upgrade again. Changing `date` to a new
449
- * value invalidates all previous flags for this operation.
458
+ * value selects a new flag file, so the upgrade runs once more.
459
+ *
460
+ * The marker is written with `setFlag` for the same reason as in
461
+ * `pkg.update`: the flag name carries only the date, so an
462
+ * evicting prefix would be host-global and sibling calls could never
463
+ * converge. Retired markers are therefore not pruned, and re-using an
464
+ * earlier date is skipped rather than re-run.
450
465
  *
451
466
  * On apt systems this runs `dpkg --configure -a`, `apt-get update`, and
452
467
  * `apt-get upgrade -y` as three separate commands (each subject to its own
@@ -504,6 +519,12 @@ declare const apt: {
504
519
  * The pipeline is split into three separate SSH commands so that each step
505
520
  * gets its own timeout window and produces a precise failure label.
506
521
  *
522
+ * The marker is written with `setFlag`: the flag name carries only the
523
+ * date and no call-site identity, so an evicting prefix would be
524
+ * host-global and two `apt.distUpgrade` calls with different dates would
525
+ * delete each other's marker on every run. Retired markers are therefore
526
+ * not pruned, and re-using an earlier date is skipped rather than re-run.
527
+ *
507
528
  * @param date - A date string used as the idempotency key (e.g. `"2024-01-15"`).
508
529
  * @param options - Optional per-call overrides (e.g. SSH command `timeout`).
509
530
  * The same `timeout` is applied to every step of the dist-upgrade pipeline.
@@ -1017,7 +1038,7 @@ type PropertiesOptions = {
1017
1038
  /**
1018
1039
  * Set file or directory ownership and permissions on the remote host.
1019
1040
  * Only the attributes specified in `options` are checked and applied. Drift
1020
- * is detected via `stat -c '%a %U %G'` so `apply` only invokes the relevant
1041
+ * is detected via `stat -c '%a %U %G %u %g'` so `apply` only invokes the relevant
1021
1042
  * `chmod`/`chown`/`chgrp` for fields that actually differ from the desired
1022
1043
  * state. When all desired fields already match, `apply` returns `status: "ok"`
1023
1044
  * instead of falsely reporting a change.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-N3n0FmKh.js';
2
- export { l as EnvironmentValue, m as ExecOptions, N as NEEDS_APPLY, n as SshConfig, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from './index-N3n0FmKh.js';
1
+ import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-CGYwJDTf.js';
2
+ export { l as EnvironmentValue, m as ExecOptions, N as NEEDS_APPLY, n as SshConfig, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from './index-CGYwJDTf.js';
3
3
 
4
4
  /**
5
5
  * Fail the run if a condition on the current env is not satisfied.
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  user,
62
62
  validateHostLabel,
63
63
  validateSshConfig
64
- } from "./chunk-56SN6NQH.js";
64
+ } from "./chunk-NXU7K6HY.js";
65
65
 
66
66
  // src/conditionalModules.ts
67
67
  function createConditionalApplyState(environment) {
@@ -1 +1 @@
1
- export { V as PackageSpec, W as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-N3n0FmKh.js';
1
+ export { V as PackageSpec, W as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-CGYwJDTf.js';
@@ -27,7 +27,7 @@ import {
27
27
  timer,
28
28
  ufw,
29
29
  user
30
- } from "../chunk-56SN6NQH.js";
30
+ } from "../chunk-NXU7K6HY.js";
31
31
  export {
32
32
  apt,
33
33
  archive,
package/llm-guide.md CHANGED
@@ -142,12 +142,12 @@ export default server({
142
142
 
143
143
  ### `apt`
144
144
 
145
- | Method | Signature | Idempotent |
146
- | ----------------- | ---------------------------------------------------------------------------------------- | -------------------- |
147
- | `apt.debconf` | `(packageName: string, selections: Record<string, string>): Module` | Yes |
148
- | `apt.distUpgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
149
- | `apt.key` | `(name: string, url: string, options: { fingerprint: string }): Module` | Yes |
150
- | `apt.repository` | `(nameOrPpa: string, source?: string, options?: { signedBy?: false \| string }): Module` | Yes |
145
+ | Method | Signature | Idempotent |
146
+ | ----------------- | ---------------------------------------------------------------------------------------- | ---------------- |
147
+ | `apt.debconf` | `(packageName: string, selections: Record<string, string>): Module` | Yes |
148
+ | `apt.distUpgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (dated flag) |
149
+ | `apt.key` | `(name: string, url: string, options: { fingerprint: string }): Module` | Yes |
150
+ | `apt.repository` | `(nameOrPpa: string, source?: string, options?: { signedBy?: false \| string }): Module` | Yes |
151
151
 
152
152
  ### `archive`
153
153
 
@@ -224,6 +224,15 @@ very slow artifact hosts or large downloads.
224
224
  | `file.stat` | `(remotePath: string): Module` | No (always-applies) |
225
225
  | `file.template` | `(remotePath: string, templatePath: string, options?: { mode?: string; owner?: string; strict?: boolean }): Module` | Yes |
226
226
 
227
+ **Note on `owner` and `group` values:** Every ownership option accepts either a POSIX name or a
228
+ numeric id — `owner: "www-data:www-data"` and `owner: "65532:65532"` are both valid, and the two
229
+ components may be mixed (`owner: "root:65532"`). Numeric ids are the only option for accounts that
230
+ have no passwd or group entry on the target host, such as `65532` in distroless images or `70` in
231
+ the postgres image. Drift detection compares the declared value against both the name and the id
232
+ reported by the host, so a numerically declared owner converges to `status: "ok"` like a named one.
233
+ `file.properties` keeps `owner` and `group` as separate options and therefore rejects a combined
234
+ `"user:group"` spec.
235
+
227
236
  **Note on `file.copy` and the default mode:** When `options.mode` is omitted, `file.copy` sets the mode to the documented default of `0644`. The mode is applied during upload (`uploadFile` with `{ mode }`) and compared with the desired value in `check`, so subsequent runs detect mode drift (for example, a manual `chmod 0600`) as `needs-apply`. Pass `{ mode: "0600" }` explicitly when more restrictive permissions are required, such as for secrets.
228
237
 
229
238
  **Note on `file.line`:** The `line` value must be a single line without CR/LF characters. Use `file.block` for multiline content.
@@ -279,12 +288,12 @@ that are expected to respond more slowly.
279
288
 
280
289
  Import with renaming: `import { package as pkg } from "paratix/modules"`. The word `package` is reserved in JavaScript, so you must alias it.
281
290
 
282
- | Method | Signature | Idempotent |
283
- | ------------------- | --------------------------------------------------------------------------------- | -------------------- |
284
- | `package.installed` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
285
- | `package.absent` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
286
- | `package.update` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
287
- | `package.upgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
291
+ | Method | Signature | Idempotent |
292
+ | ------------------- | --------------------------------------------------------------------------------- | ---------------- |
293
+ | `package.installed` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
294
+ | `package.absent` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
295
+ | `package.update` | `(date: string, options?: UpgradeOptions): Module` | Yes (dated flag) |
296
+ | `package.upgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (dated flag) |
288
297
 
289
298
  #### `UpgradeOptions`
290
299
 
@@ -533,6 +542,7 @@ function myCustomModule(configPath: string, content: string): Module {
533
542
  - Prefer `failedCommand("...", result)` when you used `ssh.exec(..., { ignoreExitCode: true })` and want stdout/stderr preserved for central runner output.
534
543
  - Return `NEEDS_APPLY` (the exported constant), never the string literal `"needs-apply"`.
535
544
  - `ModuleResult.status` must be one of: `"changed"`, `"failed"`, `"ok"`, `"skipped"`.
545
+ - Optionally return `ModuleResult.detail` with a short single-line reason; the runner appends it to the step line. Use it to say what a `changed` step actually did, and leave it unset when the step did no work. See [Status Detail on Changed Steps](#status-detail-on-changed-steps).
536
546
  - Use typed `meta` entries in the return value, not loose objects.
537
547
  - For normal downstream environment propagation, use `meta.env(name, value)`.
538
548
  - `meta.env(...)` accepts strings, numbers, booleans, sync lazy functions, and async lazy functions.
@@ -773,6 +783,43 @@ async check(ssh) {
773
783
  }
774
784
  ```
775
785
 
786
+ ## Status Detail on Changed Steps
787
+
788
+ A module may return a short `detail` next to its status. The runner appends it to
789
+ the step line, both for top-level modules and for steps inside a recipe:
790
+
791
+ ```
792
+ · · ↺ package.upgrade: 2026-03-01 changed no packages upgraded 4.1s
793
+ ```
794
+
795
+ The dated-flag modules use this to say **why** they ran. Their step name already
796
+ carries the date, so the detail reports the outcome instead of repeating the key.
797
+ This matters most for the upgrade modules: they report `changed` whenever their
798
+ dated marker is absent, even when the package manager upgraded nothing.
799
+
800
+ | Module | Detail on `changed` |
801
+ | ----------------- | ----------------------------------------------------------------------- |
802
+ | `package.update` | `marker was missing, package index refreshed` |
803
+ | `package.upgrade` | `12 packages upgraded`, `1 package upgraded`, or `no packages upgraded` |
804
+ | `apt.distUpgrade` | same forms as `package.upgrade` |
805
+
806
+ Rules:
807
+
808
+ - The count is parsed from apt's upgrade summary. `package.upgrade` also serves
809
+ `dnf`, `yum` and `apk`, whose summaries differ; those report
810
+ `upgrade completed, package count unavailable`.
811
+ - The same fallback applies when the summary line is missing from the output, or
812
+ when the captured output was truncated at `maxOutputBytes`. apt prints its
813
+ summary last, so a truncated capture loses exactly that line, and reporting no
814
+ count is preferred over reporting a wrong one.
815
+ - A step that did no work carries no detail: the flag-already-exists path returns
816
+ a plain `ok`, and failures report through the normal error path.
817
+ - Dry runs keep the generic `changed (dry-run)` suffix. The detail is produced by
818
+ `apply`, which does not run in dry-run mode.
819
+
820
+ Custom modules may return `detail` the same way; see
821
+ [Key rules for custom modules](#key-rules-for-custom-modules).
822
+
776
823
  ## Dry-Run Diff Output (`--diff`)
777
824
 
778
825
  Paratix runs a playbook in dry-run mode via `paratix apply <file> --dry-run`, which
@@ -876,7 +923,7 @@ The diff string is plain text — no ANSI codes. The output layer applies colors
876
923
  5. Use `{{KEY|shell}}` or `{{KEY|raw}}` placeholders in `.tmpl` files — strict mode is on by default and bare `{{KEY}}` will throw. Provide values via `env` in `server()`.
877
924
  6. Use `service.restart()` and `service.reload()` as `signals` in recipes, not directly in `run`.
878
925
  7. Use `signals.flush()` only as an explicit checkpoint when staged flows require an early signal flush.
879
- 8. Always pass a date string to `package.upgrade()` and `package.update()` -- it is the idempotency key.
926
+ 8. Always pass a date string to `package.upgrade()` and `package.update()` -- it is the idempotency key. Each distinct date keeps its own flag file, so several calls with different dates coexist; a date that was already applied stays applied and is not re-run. Read the step's [status detail](#status-detail-on-changed-steps) to tell a real upgrade from a step that only wrote its marker.
880
927
  9. Specify `ssh.ports` as an array -- the runner tries each port in order.
881
928
  10. Custom modules must implement both `check` and `apply`, both async.
882
929
  11. Use `shellQuote()` when interpolating dynamic values into shell commands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paratix",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [